Compare commits
21 Commits
mai/curie/
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cff38ff3c | |||
| 46b58dcf41 | |||
| 9da4715137 | |||
| 16ec8c490a | |||
| f49c804ddd | |||
| 5901d40b79 | |||
| c767b61a8a | |||
| 4f94697377 | |||
| 2a56b7817c | |||
| 75833082fc | |||
| ce28ea972e | |||
| 6f8b4eabb1 | |||
| e2d75c391d | |||
| 932b177779 | |||
| 989941c648 | |||
| db8e8ba6fd | |||
| d5bf82314a | |||
| 426b90bb88 | |||
| 07acf7b4a2 | |||
| 3e1644820a | |||
| acf5743fa3 |
23
Makefile
23
Makefile
@@ -21,7 +21,7 @@
|
||||
# the test runner's working dirs. None of them touch internal/db/migrations/
|
||||
# files.
|
||||
|
||||
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot
|
||||
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot snapshot-upc
|
||||
|
||||
help:
|
||||
@echo "Paliad — developer targets"
|
||||
@@ -33,6 +33,8 @@ help:
|
||||
@echo " test Short test pass — covers gate tier"
|
||||
@echo " test-go Full Go suite with race detector"
|
||||
@echo " test-frontend Frontend bun:test suite"
|
||||
@echo " snapshot-upc Regenerate pkg/litigationplanner/embedded/upc/ from live DB"
|
||||
@echo " (needs DATABASE_URL — see cmd/gen-upc-snapshot/README.md)"
|
||||
@echo ""
|
||||
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
||||
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
|
||||
@@ -141,3 +143,22 @@ refresh-snapshot:
|
||||
' internal/db/testdata/prod-snapshot.sql.tmp > internal/db/testdata/prod-snapshot.sql
|
||||
@rm internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@wc -l internal/db/testdata/prod-snapshot.sql
|
||||
|
||||
# Regenerate the embedded UPC snapshot from a live paliad DB. The
|
||||
# generator applies pending migrations first, then SELECTs the UPC
|
||||
# subset and writes JSON files under pkg/litigationplanner/embedded/upc/.
|
||||
#
|
||||
# Requires DATABASE_URL — Slice C of the litigation-planner extraction
|
||||
# (m/paliad#124 §19). See cmd/gen-upc-snapshot/README.md for the full
|
||||
# operator runbook.
|
||||
snapshot-upc:
|
||||
@if [ -z "$$DATABASE_URL" ]; then \
|
||||
echo "ERROR: DATABASE_URL is not set."; \
|
||||
echo " Snapshot generation needs read access to a paliad DB."; \
|
||||
echo " Set DATABASE_URL to the live paliad Postgres, then re-run."; \
|
||||
exit 2; \
|
||||
fi
|
||||
@echo "==> regenerating UPC snapshot from $$DATABASE_URL"
|
||||
go run ./cmd/gen-upc-snapshot
|
||||
@echo "==> running snapshot tests against the regenerated data"
|
||||
go test ./pkg/litigationplanner/embedded/upc/...
|
||||
|
||||
59
cmd/gen-upc-snapshot/README.md
Normal file
59
cmd/gen-upc-snapshot/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# gen-upc-snapshot
|
||||
|
||||
Regenerates the embedded UPC snapshot consumed by
|
||||
`pkg/litigationplanner/embedded/upc`. Slice C of the litigation-planner
|
||||
extraction (m/paliad#124 §19). See
|
||||
`docs/design-litigation-planner-2026-05-26.md` §19 for the full design.
|
||||
|
||||
## When to regenerate
|
||||
|
||||
After any change that affects the public UPC rule corpus:
|
||||
|
||||
- new rules merged via the admin rule-editor
|
||||
- a deadline-rule migration that touches UPC rows
|
||||
- a `paliad.holidays` update (new public holidays / vacation runs)
|
||||
- a `paliad.courts` update (new UPC LD opens, etc.)
|
||||
- a `paliad.proceeding_types` change for `jurisdiction = 'UPC'`
|
||||
|
||||
The snapshot is operator-controlled — there is no CI regeneration in v1.
|
||||
|
||||
## How to regenerate
|
||||
|
||||
```sh
|
||||
make snapshot-upc
|
||||
```
|
||||
|
||||
or directly:
|
||||
|
||||
```sh
|
||||
DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|-----------------|----------------------------------------|---------|
|
||||
| `-output` | `./pkg/litigationplanner/embedded/upc` | directory to write JSON files into |
|
||||
| `-version` | auto-derived (`YYYY-MM-DD-N`) | override the snapshot version |
|
||||
| `-source-label` | empty | text label written to `meta.json` (`paliad-prod`, `paliad-dev`, …) |
|
||||
|
||||
The generator:
|
||||
|
||||
1. Applies pending migrations against `DATABASE_URL` (snapshot always matches schema HEAD).
|
||||
2. SELECTs UPC active proceeding_types + their published+active rules + referenced trigger_events + DE/UPC holidays + UPC courts.
|
||||
3. Writes pretty-printed JSON to `<output>/{proceeding_types,rules,trigger_events,holidays,courts,meta}.json`.
|
||||
|
||||
## Idempotence
|
||||
|
||||
Running twice with the same DB state produces the same JSON (modulo `meta.generated_at`). Diff-friendly in git.
|
||||
|
||||
## Versioning
|
||||
|
||||
`meta.json.version` uses `YYYY-MM-DD-N` where N starts at 1 and increments on same-day regenerations. The generator reads the existing `meta.json` and bumps automatically.
|
||||
|
||||
## After regeneration
|
||||
|
||||
1. Review the diff: `git diff pkg/litigationplanner/embedded/upc/`.
|
||||
2. Run tests: `go test ./pkg/litigationplanner/embedded/upc/...`.
|
||||
3. Commit with a message like `chore(snapshot): regenerate UPC snapshot (<reason>)`.
|
||||
4. Notify any downstream consumer (youpc.org) that a new paliad release is available.
|
||||
301
cmd/gen-upc-snapshot/main.go
Normal file
301
cmd/gen-upc-snapshot/main.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Command gen-upc-snapshot reads paliad's live deadline corpus and
|
||||
// writes the UPC subset as JSON files under
|
||||
// pkg/litigationplanner/embedded/upc/. The package's embedded
|
||||
// catalog/holiday/court implementations then serve this data without
|
||||
// any DB roundtrip — letting youpc.org (or any future consumer) run
|
||||
// the litigationplanner engine against the canonical UPC rule set.
|
||||
//
|
||||
// Slice C (m/paliad#124 §19). See docs/design-litigation-planner-2026-05-26.md
|
||||
// §19 for the full design.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot \
|
||||
// [-output ./pkg/litigationplanner/embedded/upc] \
|
||||
// [-version 2026-05-26-1] \
|
||||
// [-source-label paliad-dev-supabase]
|
||||
//
|
||||
// The generator applies migrations against DATABASE_URL before
|
||||
// SELECTing (so the snapshot always matches schema HEAD). Idempotent —
|
||||
// running twice with the same DB state produces the same JSON.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOutput = "./pkg/litigationplanner/embedded/upc"
|
||||
defaultSourceLabel = ""
|
||||
)
|
||||
|
||||
// Meta is the version block written to meta.json. The embedded sub-
|
||||
// package re-defines this type so consumers can decode it without
|
||||
// importing the cmd; the cmd holds the canonical write shape.
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
PaliadCommit string `json:"paliad_commit,omitempty"`
|
||||
SourceDBLabel string `json:"source_db_label,omitempty"`
|
||||
RuleCount int `json:"rule_count"`
|
||||
ProceedingCount int `json:"proceeding_count"`
|
||||
TriggerEventCount int `json:"trigger_event_count"`
|
||||
HolidayCount int `json:"holiday_count"`
|
||||
CourtCount int `json:"court_count"`
|
||||
}
|
||||
|
||||
// EmbeddedHoliday is the holiday row shape the embedded snapshot
|
||||
// stores. JSON tags mirror paliad.holidays so the generator's SELECT
|
||||
// scans onto it directly + the embedded HolidayCalendar reads the
|
||||
// same tag.
|
||||
type EmbeddedHoliday struct {
|
||||
Date string `db:"date_iso" json:"date"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Country *string `db:"country" json:"country,omitempty"`
|
||||
Regime *string `db:"regime" json:"regime,omitempty"`
|
||||
State *string `db:"state" json:"state,omitempty"`
|
||||
HolidayType string `db:"holiday_type" json:"holiday_type"`
|
||||
}
|
||||
|
||||
// EmbeddedCourt is the court row shape the embedded snapshot stores.
|
||||
type EmbeddedCourt struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Country string `db:"country" json:"country"`
|
||||
Regime *string `db:"regime" json:"regime,omitempty"`
|
||||
CourtType string `db:"court_type" json:"court_type"`
|
||||
ParentID *string `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
output := flag.String("output", defaultOutput, "directory to write JSON files into")
|
||||
version := flag.String("version", "", "explicit snapshot version (auto-derived if empty)")
|
||||
sourceLabel := flag.String("source-label", defaultSourceLabel, "label for source_db in meta.json")
|
||||
flag.Parse()
|
||||
|
||||
url := os.Getenv("DATABASE_URL")
|
||||
if url == "" {
|
||||
log.Fatal("DATABASE_URL must be set")
|
||||
}
|
||||
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
log.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
log.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := run(ctx, pool, *output, *version, *sourceLabel); err != nil {
|
||||
log.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string) error {
|
||||
if err := os.MkdirAll(output, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir output: %w", err)
|
||||
}
|
||||
|
||||
// 1. Proceeding types — UPC + active only. The unified upc.apl row
|
||||
// from B1 mig 134 is included; the 3 archived old appeal codes
|
||||
// (is_active=false) are filtered out by the WHERE.
|
||||
var procs []litigationplanner.ProceedingType
|
||||
if err := pool.SelectContext(ctx, &procs, `
|
||||
SELECT id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target
|
||||
FROM paliad.proceeding_types
|
||||
WHERE jurisdiction = 'UPC' AND is_active = true
|
||||
ORDER BY sort_order, id`); err != nil {
|
||||
return fmt.Errorf("select proceeding_types: %w", err)
|
||||
}
|
||||
|
||||
if len(procs) == 0 {
|
||||
return fmt.Errorf("no active UPC proceeding_types — refusing to write empty snapshot")
|
||||
}
|
||||
|
||||
procIDs := make([]int, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
procIDs = append(procIDs, p.ID)
|
||||
}
|
||||
|
||||
// 2. Deadline rules — published + active rules for those proceedings.
|
||||
const ruleCols = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type, duration_value,
|
||||
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||
choices_offered, applies_to_target`
|
||||
|
||||
q, args, err := sqlx.In(`
|
||||
SELECT `+ruleCols+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id IN (?)
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
ORDER BY proceeding_type_id, sequence_order`, procIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build rules IN: %w", err)
|
||||
}
|
||||
q = pool.Rebind(q)
|
||||
var rules []litigationplanner.Rule
|
||||
if err := pool.SelectContext(ctx, &rules, q, args...); err != nil {
|
||||
return fmt.Errorf("select rules: %w", err)
|
||||
}
|
||||
|
||||
// 3. Trigger events referenced by any UPC rule's trigger_event_id.
|
||||
triggerIDSet := make(map[int64]struct{})
|
||||
for _, r := range rules {
|
||||
if r.TriggerEventID != nil {
|
||||
triggerIDSet[*r.TriggerEventID] = struct{}{}
|
||||
}
|
||||
}
|
||||
var triggers []litigationplanner.TriggerEvent
|
||||
if len(triggerIDSet) > 0 {
|
||||
triggerIDs := make([]int64, 0, len(triggerIDSet))
|
||||
for id := range triggerIDSet {
|
||||
triggerIDs = append(triggerIDs, id)
|
||||
}
|
||||
q, args, err := sqlx.In(`
|
||||
SELECT id, code, name, name_de, description, is_active, created_at
|
||||
FROM paliad.trigger_events
|
||||
WHERE id IN (?)
|
||||
ORDER BY id`, triggerIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build triggers IN: %w", err)
|
||||
}
|
||||
q = pool.Rebind(q)
|
||||
if err := pool.SelectContext(ctx, &triggers, q, args...); err != nil {
|
||||
return fmt.Errorf("select trigger_events: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Holidays — DE national + UPC regime entries. The embedded
|
||||
// calendar serves UPC computations so both axes matter.
|
||||
var holidays []EmbeddedHoliday
|
||||
if err := pool.SelectContext(ctx, &holidays, `
|
||||
SELECT to_char(date, 'YYYY-MM-DD') AS date_iso,
|
||||
name, country, regime, state, holiday_type
|
||||
FROM paliad.holidays
|
||||
WHERE country = 'DE' OR regime = 'UPC'
|
||||
ORDER BY date, name`); err != nil {
|
||||
return fmt.Errorf("select holidays: %w", err)
|
||||
}
|
||||
|
||||
// 5. Courts — UPC subset.
|
||||
var courts []EmbeddedCourt
|
||||
if err := pool.SelectContext(ctx, &courts, `
|
||||
SELECT id, code, name_de, name_en, country, regime, court_type, parent_id, sort_order
|
||||
FROM paliad.courts
|
||||
WHERE is_active = true
|
||||
AND (regime = 'UPC' OR court_type LIKE 'upc%')
|
||||
ORDER BY sort_order, id`); err != nil {
|
||||
return fmt.Errorf("select courts: %w", err)
|
||||
}
|
||||
|
||||
// 6. Compose meta.
|
||||
meta := Meta{
|
||||
Version: resolveVersion(version, output),
|
||||
GeneratedAt: time.Now().UTC().Truncate(time.Second),
|
||||
PaliadCommit: gitCommitShort(),
|
||||
SourceDBLabel: sourceLabel,
|
||||
RuleCount: len(rules),
|
||||
ProceedingCount: len(procs),
|
||||
TriggerEventCount: len(triggers),
|
||||
HolidayCount: len(holidays),
|
||||
CourtCount: len(courts),
|
||||
}
|
||||
|
||||
// 7. Write each file.
|
||||
files := []struct {
|
||||
name string
|
||||
data any
|
||||
}{
|
||||
{"proceeding_types.json", procs},
|
||||
{"rules.json", rules},
|
||||
{"trigger_events.json", triggers},
|
||||
{"holidays.json", holidays},
|
||||
{"courts.json", courts},
|
||||
{"meta.json", meta},
|
||||
}
|
||||
for _, f := range files {
|
||||
path := filepath.Join(output, f.name)
|
||||
buf, err := json.MarshalIndent(f.data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s: %w", f.name, err)
|
||||
}
|
||||
buf = append(buf, '\n')
|
||||
if err := os.WriteFile(path, buf, 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("snapshot written: version=%s rules=%d proceedings=%d triggers=%d holidays=%d courts=%d → %s",
|
||||
meta.Version, meta.RuleCount, meta.ProceedingCount,
|
||||
meta.TriggerEventCount, meta.HolidayCount, meta.CourtCount, output)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveVersion picks a date-stamped version slug, bumping the suffix
|
||||
// past any pre-existing same-day version found in the existing
|
||||
// meta.json. If the caller passed -version, that wins.
|
||||
func resolveVersion(explicit, output string) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
// Read prior meta to detect same-day collisions.
|
||||
prior, err := os.ReadFile(filepath.Join(output, "meta.json"))
|
||||
if err != nil {
|
||||
return today + "-1"
|
||||
}
|
||||
var pm Meta
|
||||
if err := json.Unmarshal(prior, &pm); err != nil {
|
||||
return today + "-1"
|
||||
}
|
||||
if !strings.HasPrefix(pm.Version, today+"-") {
|
||||
return today + "-1"
|
||||
}
|
||||
// Same day: bump the suffix.
|
||||
suffix := pm.Version[len(today)+1:]
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(suffix, "%d", &n); err != nil {
|
||||
return today + "-1"
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", today, n+1)
|
||||
}
|
||||
|
||||
// gitCommitShort returns the short SHA of the paliad checkout. Best-
|
||||
// effort — empty string when we're not in a git checkout.
|
||||
func gitCommitShort() string {
|
||||
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
@@ -1141,4 +1141,478 @@ Slice F is a youpc-side task; it needs a worker with youpc-go familiarity (a sep
|
||||
|
||||
---
|
||||
|
||||
## §18 Slice B — Catalog Interface + Unifications (2026-05-26)
|
||||
|
||||
Slice A landed atomically at `d1d0cf9`. Before Slice B's coder shift begins, three additional decisions m confirmed today need to be folded into the package design:
|
||||
|
||||
- §18.1 — **Berufung unification**. Collapse the 3 active UPC appeal proceeding_types (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) into ONE `upc.apl` proceeding type + an `appeal_target` discriminator.
|
||||
- §18.2 — **Multi-axis catalog query API**. New `Catalog.LookupEvents` method taking any subset of `{jurisdiction, proceeding_type_id, party, event_category_id}` axes + a depth control (`next` / `all-following`).
|
||||
- §18.3 — **`primary_party` enum tightening**. Convert the free-text `paliad.deadline_rules.primary_party` column to a CHECK constraint matching the four-value vocabulary `claimant / defendant / court / both`.
|
||||
|
||||
Each subsection follows the same shape: motivation → schema impact → API shape → acceptance criteria.
|
||||
|
||||
### §18.0 Live state on main (audit summary)
|
||||
|
||||
Confirmed via Supabase before drafting (`mai/cronus/inventor-litigation-slice-b` branch off `main`):
|
||||
|
||||
- **9 active UPC proceeding_types**: `upc.inf.cfi` (25 rules), `upc.rev.cfi` (17), `upc.pi.cfi` (7), `upc.dmgs.cfi` (8), `upc.disc.cfi` (4), `upc.ccr.cfi` (0 — sub-track), `upc.apl.merits` (7), `upc.apl.cost` (2), `upc.apl.order` (7).
|
||||
- **3 appeal-flavoured proceeding_types** = 16 rules across 3 codes. Schadensbemessung + Bucheinsicht are SEPARATE first-instance proceedings today (`upc.dmgs.cfi`, `upc.disc.cfi`), NOT appeal sub-tracks.
|
||||
- **`paliad.deadline_rules.primary_party`** value distribution: `claimant=26`, `defendant=26`, `court=38`, `both=63`, `NULL=78`. The 78 NULL rows are ALL `proceeding_type_id IS NULL` orphans (cross-cutting concept seeds: Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung — 8 distinct concepts × N rules). Every proceeding-bound rule already has a four-value `primary_party`.
|
||||
- **`paliad.event_categories.party`** column shape: `text[]` (array). Live distinct values: `{claimant}`, `{defendant}`, NULL. No `court` or `both` in event_categories.party today. The semantic is "from whose perspective is this event triggered?" — narrower than `primary_party` which is "who files this submission".
|
||||
|
||||
### §18.1 Berufung unification
|
||||
|
||||
#### Motivation
|
||||
|
||||
m's framing (2026-05-26 09:55, t-paliad-298 instructions): *"the Verfahrensablauf event picker has 4-5 separate proceeding_types … plus Berufung Schadensbemessung and Berufung Bucheinsicht variants. m doesn't like the pre-separation. He wants ONE 'Berufung' entry in the picker, and the user then picks what the appeal is directed AT … the system derives the correct frist sequence from that target."*
|
||||
|
||||
Today's 3 codes (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) are a leaky abstraction of "appeal" — the user has to know whether it's a merits/cost/order appeal BEFORE they enter the picker, even though that branching question is "what's being appealed?" not "what kind of appeal?". Schadensbemessung + Bucheinsicht aren't in the appeal taxonomy at all today; appeals against those decisions silently fall into `upc.apl.merits`, blurring the rule sequence (RoP.220 vs RoP.221 vs RoP.224 timing).
|
||||
|
||||
The five appeal-target kinds are:
|
||||
|
||||
| Target | Source decision | Typical RoP track | Current proceeding code |
|
||||
|---|---|---|---|
|
||||
| Endentscheidung | Final merits decision (UPC.RoP.118.1 / 219) | 2-month notice + 4-month grounds (R.224.1.a / R.224.2.a) | `upc.apl.merits` |
|
||||
| Kostenentscheidung | Cost decision (R.150 / R.221.1) | 15-day leave-to-appeal (R.221.1) | `upc.apl.cost` |
|
||||
| Anordnung | Order during proceedings (R.220) | 15-day track (R.220.2 / R.220.3 / R.224.2.b) | `upc.apl.order` |
|
||||
| Schadensbemessung | Damages-determination decision (R.118.4 + R.140.2.b damages award) | Same merits track (2/4 month), but conceptually distinct anchor | (today maps to `upc.apl.merits`, silently) |
|
||||
| Bucheinsicht | Lay-open-books decision (R.142) | 15-day track (R.220.2 — order-flavoured) OR merits track depending on the underlying decision shape | (today maps to `upc.apl.merits`, silently) |
|
||||
|
||||
#### Schema impact
|
||||
|
||||
**Migration plan (single `134_berufung_unification.up.sql`)**:
|
||||
|
||||
1. **Add column** `paliad.proceeding_types.appeal_target text NULL` — discriminator on the unified `upc.apl` row.
|
||||
2. **Add CHECK** on `appeal_target`: NULL OR one of `endentscheidung | kostenentscheidung | anordnung | schadensbemessung | bucheinsicht`. Slugged in English-lowercase to match the package's English-identifier rule; the user-facing label is i18n'd in frontend.
|
||||
3. **Insert** a new unified row `upc.apl` (name="Berufungsverfahren", name_en="Appeal", jurisdiction="UPC", category="fristenrechner", `appeal_target=NULL`).
|
||||
4. **Re-target rule rows** by `appeal_target`:
|
||||
- Today's 7 `upc.apl.merits` rules → keep `proceeding_type_id` pointing at the new `upc.apl` row, set a NEW column on `paliad.deadline_rules` called `applies_to_target text NULL` (CHECK matching the five-value vocab) to `'endentscheidung'`.
|
||||
- Today's 2 `upc.apl.cost` rules → `applies_to_target='kostenentscheidung'`.
|
||||
- Today's 7 `upc.apl.order` rules → `applies_to_target='anordnung'`.
|
||||
- The 7 merits rules ALSO carry the implicit "applies to Schadensbemessung" semantic (the merits track is shared) — explicit duplication or a multi-value applies_to_target array? See §18.1 "Open question" below.
|
||||
5. **Archive** the 3 old proceeding_types — set `category='archived'`, `is_active=false`. Keep the rows for FK integrity (project_event_choices, etc. may reference them historically; the archive flag stops them surfacing in the picker).
|
||||
6. **Add 5 stable proceeding-type alias rows** OR **just emit one chip per appeal_target in the frontend**. Recommended (see API shape below): emit chips from the package's catalog, no DB row per target.
|
||||
|
||||
**Two new columns added by this migration:**
|
||||
- `paliad.proceeding_types.appeal_target text NULL` (CHECK on 5 slugs OR NULL — NULL means "not an appeal").
|
||||
- `paliad.deadline_rules.applies_to_target text[] NULL` (CHECK each element ∈ the 5 slugs — array because the merits track applies to BOTH endentscheidung AND schadensbemessung today).
|
||||
|
||||
**Migration audit pass first**: before running step 4 the migration should `RAISE NOTICE` for any rule row whose `applies_to_target` derivation is ambiguous (e.g. an old `upc.apl.merits` rule that has a `condition_flag` that doesn't fit any target). In practice the 16 rules all map cleanly, but the audit pattern matches Phase 2 Step E discipline (see `docs/design-fristen-phase2-2026-05-15.md` §3.E).
|
||||
|
||||
**Down-migration**: re-insert the 3 archived proceeding_types, restore `proceeding_type_id` on rules from the saved `applies_to_target`, drop the two new columns. Standard down-symmetry per `docs/design-fristen-phase2-2026-05-15.md`.
|
||||
|
||||
#### API shape
|
||||
|
||||
The package's existing `Catalog.LoadProceeding(ctx, code, hint)` already returns a `ProceedingType` + `[]Rule`. The Berufung unification fits cleanly:
|
||||
|
||||
- `LoadProceeding(ctx, "upc.apl", hint)` returns the unified Berufung proceeding + ALL appeal rules across the 5 targets.
|
||||
- A new optional field on the request narrows by target: extend `CalcOptions` with `AppealTarget string`. When non-empty, the engine filters the returned rule list to rules whose `applies_to_target` contains the requested target.
|
||||
- The package exposes the 5 target slugs as constants:
|
||||
|
||||
```go
|
||||
const (
|
||||
AppealTargetEndentscheidung = "endentscheidung"
|
||||
AppealTargetKostenentscheidung = "kostenentscheidung"
|
||||
AppealTargetAnordnung = "anordnung"
|
||||
AppealTargetSchadensbemessung = "schadensbemessung"
|
||||
AppealTargetBucheinsicht = "bucheinsicht"
|
||||
)
|
||||
|
||||
// AppealTargets is the canonical ordered list for UI chip rendering.
|
||||
var AppealTargets = []string{
|
||||
AppealTargetEndentscheidung,
|
||||
AppealTargetKostenentscheidung,
|
||||
AppealTargetAnordnung,
|
||||
AppealTargetSchadensbemessung,
|
||||
AppealTargetBucheinsicht,
|
||||
}
|
||||
```
|
||||
|
||||
- `ProceedingType` gains a field: `AppealTarget *string ` db:"appeal_target" json:"appealTarget,omitempty"`` (per-row tag for clarity; redundant with the unified row's `code='upc.apl'` but useful for non-appeal proceedings that may carry NULL).
|
||||
- `Rule` gains a field: `AppliesToTarget []string ` db:"applies_to_target" json:"appliesToTarget,omitempty"`` (per-row applies-to set).
|
||||
|
||||
Frontend logic:
|
||||
- Verfahrensablauf picker shows one "Berufung" entry (the `upc.apl` proceeding).
|
||||
- After picking Berufung, a chip group renders the 5 `AppealTargets` slugs (i18n labels in `frontend/src/client/i18n.ts`).
|
||||
- Selecting a target sets `?target=<slug>` query param → backend includes `opts.AppealTarget=<slug>` in the request → engine filters.
|
||||
|
||||
#### Acceptance criteria (Slice B sub-tasks for this fold-in)
|
||||
|
||||
1. Migration `134_berufung_unification.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB.
|
||||
2. After migration, `SELECT code FROM paliad.proceeding_types WHERE jurisdiction='UPC' AND is_active=true AND category='fristenrechner'` returns one less row (the 3 old appeal codes collapsed to 1 new code).
|
||||
3. `Catalog.LoadProceeding(ctx, "upc.apl", hint)` returns the merged 16-rule set; with `opts.AppealTarget="endentscheidung"` it returns exactly 7 rules.
|
||||
4. Verfahrensablauf renders one "Berufung" picker entry. The 5 target chips render below it post-pick; switching chips re-renders the timeline.
|
||||
5. Existing project rows that referenced the old `upc.apl.merits` / `upc.apl.cost` / `upc.apl.order` codes still load (the FK integrity is preserved via the archived old rows).
|
||||
6. The `paliad.proceeding_type_history` follow-up (not in scope here) can later migrate those project FKs to the new `upc.apl` + `appeal_target` field — that's a follow-up.
|
||||
|
||||
#### m's answer on Q18.1.1 (2026-05-26 13:40)
|
||||
|
||||
> Schadensbemessung-as-appeal is a NEW appeal target. Model it as a first-class entry in the appeal_target enum with its own rule set (no shared inheritance from upc.apl.merits). The rules don't exist yet in the catalog; for now the appeal_target value is defined and `CalcOptions.AppealTarget="schadensbemessung"` returns an empty sequence until rules are seeded.
|
||||
|
||||
→ **Option B wins (m overrode inventor's R=A).** Migration 134 still ships the schema + the 5 enum values + the chip group + the engine filter. Existing rules:
|
||||
- 7 `upc.apl.merits` rules → `applies_to_target=['endentscheidung']` (Endentscheidung only — NOT also Schadensbemessung).
|
||||
- 2 `upc.apl.cost` rules → `applies_to_target=['kostenentscheidung']`.
|
||||
- 7 `upc.apl.order` rules → `applies_to_target=['anordnung']`.
|
||||
- **Schadensbemessung + Bucheinsicht get NO rules in this migration.** `applies_to_target='schadensbemessung'` and `'bucheinsicht'` are valid enum values but no rule row carries them yet.
|
||||
|
||||
**Frontend behaviour with empty rule sets:**
|
||||
- All 5 target chips still render (the picker promises the user a complete vocabulary).
|
||||
- Picking Schadensbemessung or Bucheinsicht returns an empty timeline with a banner: "Frist-Sequenz für diesen Berufungstyp ist noch nicht hinterlegt — bitte über /admin/rules einpflegen oder Migrations-Follow-up abwarten."
|
||||
- Picking the 3 populated targets renders normally.
|
||||
|
||||
**Rule-seeding follow-up (TODO, separate slice):**
|
||||
- Schadensbemessung-appeal rules: anchor on R.118.4 (Folgeentscheidung Schadensbemessung) decision; conjecture 2/4-month track but distinct legal basis.
|
||||
- Bucheinsicht-appeal rules: anchor on R.142 (Lay-open-books decision); conjecture 15-day track per R.220.2 + R.224.2.b.
|
||||
- Can pair with `t-paliad-193` orphan-concept-seed if m wants a combined seeding pass.
|
||||
- Either path: editorial via `/admin/rules` (rule-editor service, Slice 11a) so the lawyer team can author + audit.
|
||||
|
||||
**Q18.1.2 — User-facing label for "appeal_target": "Worauf richtet sich die Berufung?" (DE) / "Appeal against:" (EN)?**
|
||||
|
||||
Recommendation: yes, those exact strings; defer i18n decisions to the coder shift.
|
||||
|
||||
### §18.2 Multi-axis catalog query API
|
||||
|
||||
#### Motivation
|
||||
|
||||
The current `Catalog` interface (added in Slice A) supports proceeding-code lookups only. The new scenarios surface (Slice D) + the Determinator cascade (t-paliad-166) + a future "show me all next-step events when I'm in state X" need a generalised query that takes any subset of axes and returns matching events.
|
||||
|
||||
m's brief (2026-05-26 13:33): *"any subset of these axes (all optional): jurisdiction, proceeding_type_id, party, event_category_id. Returns matching events with the priority flag and a sequence-depth control: caller picks 'next' (1 hop downstream) or 'all-following' (full chain)."*
|
||||
|
||||
Today the cascade reconstructs this client-side via fanned-out calls to `/api/tools/fristenrechner` etc. — fragile + duplicated logic. The new method centralises the graph walk in the package.
|
||||
|
||||
#### Schema impact
|
||||
|
||||
**None new.** The query reads existing tables:
|
||||
|
||||
- `paliad.proceeding_types` (jurisdiction, id, code)
|
||||
- `paliad.deadline_rules` (parent_id, sequence_order, primary_party, priority, event_category_id via concept_id → event_category_concepts)
|
||||
- `paliad.event_categories` (id, party, parent_id for the cascade hierarchy)
|
||||
- `paliad.event_category_concepts` (junction; concept_id → event_category_id)
|
||||
|
||||
The depth control is a runtime graph walk — `next` returns one hop from the matched parent, `all-following` walks `parent_id` recursively until leaves.
|
||||
|
||||
The audit found one schema gap worth flagging but NOT changing in Slice B:
|
||||
- `paliad.deadline_rules` has no direct `event_category_id` column — it goes through `concept_id → deadline_concepts → event_category_concepts → event_categories`. The join is well-trodden but introduces an extra hop. v1 of the catalog API uses the join; a future denormalisation (`paliad.deadline_rules.event_category_id` cached column) is out of scope.
|
||||
|
||||
#### API shape
|
||||
|
||||
```go
|
||||
// EventLookupAxes carries the optional filter axes for LookupEvents. All
|
||||
// fields are optional; the empty value is "no filter on this axis". When
|
||||
// multiple axes are set the engine applies them as AND (a rule must
|
||||
// match ALL non-zero axes).
|
||||
type EventLookupAxes struct {
|
||||
Jurisdiction string // "UPC" | "DE" | "EPA" | "DPMA" — empty = any
|
||||
ProceedingTypeID *int // narrow to one proceeding — nil = any
|
||||
Party string // "claimant" | "defendant" | "court" | "both" — empty = any
|
||||
EventCategoryID *uuid.UUID // narrow to one event_categories row — nil = any
|
||||
AppealTarget string // §18.1 fold-in — empty = any
|
||||
}
|
||||
|
||||
// EventLookupDepth controls the sequence-depth of the returned events.
|
||||
type EventLookupDepth string
|
||||
|
||||
const (
|
||||
// EventLookupDepthNext returns immediate children of the matched
|
||||
// anchor (1 hop downstream). Default for "what comes next from
|
||||
// this point?" queries.
|
||||
EventLookupDepthNext EventLookupDepth = "next"
|
||||
// EventLookupDepthAllFollowing returns the entire downstream
|
||||
// chain (parent_id walk to leaves). Default for "show me the
|
||||
// whole sequence from here onward" queries.
|
||||
EventLookupDepthAllFollowing EventLookupDepth = "all-following"
|
||||
)
|
||||
|
||||
// EventMatch is one result row from LookupEvents.
|
||||
type EventMatch struct {
|
||||
Rule Rule `json:"rule"` // full rule row
|
||||
ProceedingType ProceedingType `json:"proceedingType"` // owning proceeding
|
||||
Priority string `json:"priority"` // mandatory|recommended|optional|informational
|
||||
DepthFromAnchor int `json:"depthFromAnchor"` // 1 = next, 2+ = deeper
|
||||
// ParentRuleID populated when the match has a parent_id in the
|
||||
// returned set (so the frontend can render a tree).
|
||||
ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
|
||||
}
|
||||
|
||||
// LookupEvents on the Catalog interface returns events matching any
|
||||
// subset of axes, at the requested sequence depth. Returns an empty
|
||||
// slice (NOT an error) when no events match.
|
||||
//
|
||||
// Implementation must respect the catalog's "published + active" rule
|
||||
// gate that LoadProceeding already enforces.
|
||||
type Catalog interface {
|
||||
// ... existing methods (LoadProceeding, LoadProceedingByID, ...)
|
||||
|
||||
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
|
||||
}
|
||||
```
|
||||
|
||||
paliad's `paliadCatalog` impl builds one SQL query with optional WHERE clauses + the existing `deadline_concept_event_types` JOIN for the event_category_id axis. youpc.org's embedded snapshot impl runs the same axis-filter pass on the in-memory rule slice.
|
||||
|
||||
#### Acceptance criteria (Slice B sub-tasks)
|
||||
|
||||
1. `Catalog.LookupEvents` exists on the interface + has both a paliad-side impl (SQL) and a stub for the future embedded/upc snapshot impl.
|
||||
2. Round-trip test: `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all 77 UPC rules (matches the count from §0).
|
||||
3. Combined-axis test: `EventLookupAxes{Jurisdiction:"UPC", Party:"claimant"}` returns the claimant-perspective subset.
|
||||
4. Depth test: with a specific `ProceedingTypeID` + `Party:"defendant"`, `EventLookupDepthNext` returns only 1-hop children of the proceeding's root; `EventLookupDepthAllFollowing` returns the full chain.
|
||||
5. New axis-driven endpoint at `GET /api/tools/lookup-events?…` proxies the call (separate slice — out of scope for the package-side acceptance, but listed for the coder).
|
||||
|
||||
### §18.3 `primary_party` enum tightening
|
||||
|
||||
#### Motivation
|
||||
|
||||
Today `paliad.deadline_rules.primary_party` is free-text. The live values confirm a stable four-value vocabulary (`claimant=26, defendant=26, court=38, both=63, NULL=78`) but nothing prevents a future rule editor from typing `clamant` or `Court` and silently breaking the appellant-context propagation in `engine.go`.
|
||||
|
||||
m's brief: *"Tighten to a check constraint matching event_categories.party's allowed values: claimant/defendant/court/both. Migration must audit + clean existing rows first; surface dirty rows to m if any don't fit the four-value vocabulary."*
|
||||
|
||||
Note: `event_categories.party` is a `text[]` (array) with current live values `{claimant}`, `{defendant}`, NULL. It does NOT carry `court` or `both` today. The brief's "matching event_categories.party's allowed values" is taken to mean the SEMANTIC vocabulary (claimant/defendant/court/both), not the literal current rows of event_categories.party. The package owns the canonical list.
|
||||
|
||||
#### Schema impact
|
||||
|
||||
**Migration `135_primary_party_check.up.sql`**:
|
||||
|
||||
1. **Audit pass** (DO $$ block): COUNT rules where `proceeding_type_id IS NOT NULL AND primary_party NOT IN ('claimant', 'defendant', 'court', 'both', NULL)`. RAISE NOTICE for each non-matching row's `(id, name, primary_party)`. If COUNT > 0, RAISE EXCEPTION 'dirty rows — see notice; manual cleanup required'.
|
||||
2. **Add CHECK constraint**: `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'))`.
|
||||
3. **No data change** — every proceeding-bound rule already has a valid four-value value; the 78 NULL rows are orphan concept seeds and stay NULL.
|
||||
4. **Down-migration**: `ALTER TABLE paliad.deadline_rules DROP CONSTRAINT deadline_rules_primary_party_chk`. No data revert needed.
|
||||
|
||||
**Why NULL stays valid:**
|
||||
- The 78 NULL rows are cross-cutting concept seeds (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) that have NO proceeding binding. They're not in the calculator's path; loosening the CHECK to `IS NULL OR IN (...)` keeps them valid without further schema gymnastics.
|
||||
- A stricter "NOT NULL when proceeding_type_id is NOT NULL" partial constraint would be cleaner but adds a multi-column rule that's harder to maintain. The simpler form suffices given today's invariant.
|
||||
|
||||
**Should the same vocabulary be propagated to `paliad.event_categories.party`?**
|
||||
|
||||
Recommendation: **NO, not in this migration**. event_categories.party is array-shaped (a category can apply to multiple perspectives) and today carries only `{claimant}` / `{defendant}` per its narrower semantic ("from whose perspective is this category triggered?"). Tightening it to require court/both would force backfill of rows where neither perspective is the trigger. Out of scope for Slice B; flag as a follow-up.
|
||||
|
||||
#### API shape
|
||||
|
||||
The package's `Rule.PrimaryParty` field (already `*string`) stays as-is — the type doesn't change. A new package-level set of constants:
|
||||
|
||||
```go
|
||||
const (
|
||||
PrimaryPartyClaimant = "claimant"
|
||||
PrimaryPartyDefendant = "defendant"
|
||||
PrimaryPartyCourt = "court"
|
||||
PrimaryPartyBoth = "both"
|
||||
)
|
||||
|
||||
// PrimaryParties is the canonical ordered list for validation +
|
||||
// admin-UI rendering.
|
||||
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 (Slice E2 if
|
||||
// ever revisited) to validate writes before they hit the CHECK.
|
||||
func IsValidPrimaryParty(s string) bool { … }
|
||||
```
|
||||
|
||||
The rule-editor service (`internal/services/rule_editor_service.go`) gains a validation call against `lp.IsValidPrimaryParty` before any UPDATE — surfaces a user-friendly 400 before the DB CHECK fires with the less-pretty error.
|
||||
|
||||
#### Acceptance criteria (Slice B sub-tasks)
|
||||
|
||||
1. Migration `135_primary_party_check.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB.
|
||||
2. Pre-migration audit pass surfaces zero dirty rows on the current live corpus (verified via Supabase audit before migration drafted).
|
||||
3. Post-migration, attempting to UPDATE a rule's primary_party to `'foo'` raises a DB CHECK violation.
|
||||
4. The package exposes the four constants + the `PrimaryParties` slice + the `IsValidPrimaryParty` predicate.
|
||||
5. Rule-editor service surfaces 400 with a clear message when a write violates the constraint (instead of leaking the raw PG error).
|
||||
|
||||
### §18 Summary table
|
||||
|
||||
| § | Topic | Schema delta | API delta | Migration |
|
||||
|---|---|---|---|---|
|
||||
| 18.1 | Berufung unification | +2 columns (proceeding_types.appeal_target + deadline_rules.applies_to_target[]); collapse 3→1 active appeal codes | `Catalog` returns merged proceeding + Rule.AppliesToTarget; CalcOptions.AppealTarget filter; AppealTargets[] constants | `134_berufung_unification.up.sql` |
|
||||
| 18.2 | Multi-axis catalog query | None new (uses existing joins) | `Catalog.LookupEvents(axes, depth)` new method + EventLookupAxes / EventMatch types | None |
|
||||
| 18.3 | `primary_party` enum | +CHECK constraint on deadline_rules.primary_party | `PrimaryParties[]` constants + `IsValidPrimaryParty()` predicate | `135_primary_party_check.up.sql` |
|
||||
|
||||
### §18.4 Slice plan refinement (revises §10 for Slice B)
|
||||
|
||||
The original §10 listed Slice B as "Catalog / HolidayCalendar / CourtRegistry interfaces + paliad's default loaders." Slice A already folded those interfaces in (the engine.Calculate signature accepts them). Slice B's revised scope:
|
||||
|
||||
1. **Slice B1 — Berufung unification** (§18.1): migration 134 + package constants + `appeal_target` field on ProceedingType + `applies_to_target[]` on Rule + `CalcOptions.AppealTarget` filter. Frontend updates (verfahrensablauf chip group) follow in the same PR.
|
||||
2. **Slice B2 — Multi-axis catalog query API** (§18.2): `Catalog.LookupEvents` method + paliad impl + tests. New `GET /api/tools/lookup-events` endpoint optional (slice C may want it earlier).
|
||||
3. **Slice B3 — primary_party enum tightening** (§18.3): migration 135 + package constants + rule-editor validation hook.
|
||||
|
||||
B1 / B2 / B3 are independently shippable and can land in any order. B1 has the most user-facing impact (the picker change is what m flagged); B3 is the smallest hardening; B2 is the largest API surface.
|
||||
|
||||
### §18.5 Open questions escalated to head
|
||||
|
||||
- §18.1 Q1 — Schadensbemessung-as-appeal: shared vs distinct vs deferred (R: shared, multi-valued `applies_to_target`).
|
||||
- §18.1 Q2 — i18n label for "Worauf richtet sich die Berufung?" (R: yes, defer to coder).
|
||||
- §18.3 — Should event_categories.party be tightened in the same migration? (R: no, separate follow-up.)
|
||||
|
||||
No `AskUserQuestion` per inventor protocol; head escalates to m if material.
|
||||
|
||||
---
|
||||
|
||||
## §19 Slice C — embedded UPC snapshot + generator (2026-05-26)
|
||||
|
||||
Slice A landed the package, Slice B added the catalog API surface. Slice C lays the foundation for the youpc.org cross-repo integration: an in-package UPC subset of paliad's deadline corpus, embedded as JSON, that youpc.org can use to run the engine without any paliad DB access.
|
||||
|
||||
### §19.1 Goals
|
||||
|
||||
1. **Zero DB dependency for snapshot consumers.** youpc.org imports `pkg/litigationplanner/embedded/upc` and gets a working Catalog / HolidayCalendar / CourtRegistry without ever touching paliad's Postgres.
|
||||
2. **Reproducible regeneration.** A generator binary (`cmd/gen-upc-snapshot`) reads paliad's live DB and produces the JSON. Idempotent — same DB state in, same JSON out.
|
||||
3. **Versioned snapshots.** Each snapshot carries a `version` + `generated_at` so consumers can detect regeneration and decide whether to bump their go.mod.
|
||||
4. **Stays in lockstep with paliad's engine.** The embedded data conforms to the same `Rule` / `ProceedingType` Go types the engine consumes — no schema drift, no parallel-vocab risk.
|
||||
|
||||
### §19.2 Embedding format
|
||||
|
||||
**Pick: `//go:embed` of JSON.**
|
||||
|
||||
Three candidates considered:
|
||||
- A. **`//go:embed` of JSON files** — generator emits human-readable JSON; package reads at boot via `embed.FS`. Diff-friendly in git; youpc.org sees the bytes change in code review.
|
||||
- B. **Generated Go const literals** — generator emits a `.go` file with the rule slice inlined. Type-safe at compile; harder to diff (big generated files); pollutes `git log -p` with mechanical changes.
|
||||
- C. **External resource fetched at runtime** — youpc.org would HTTP-GET the snapshot from a paliad endpoint. Adds runtime coupling between the two services; defeats the "zero DB dependency" goal.
|
||||
|
||||
**(R) = A**. JSON is the wire shape paliad's API already serves; the package's `Rule` struct already has compatible `json:` tags from Slice A. The generated bytes survive `git diff` cleanly. youpc.org can also vendor the JSON via go-module if they want fully reproducible builds.
|
||||
|
||||
### §19.3 File layout
|
||||
|
||||
```
|
||||
pkg/litigationplanner/embedded/upc/
|
||||
embed.go ← //go:embed *.json + package metadata
|
||||
snapshot.go ← SnapshotCatalog struct + Load() helper
|
||||
snapshot_test.go ← unit tests against the embedded data
|
||||
rules.json ← generator output: all UPC rules
|
||||
proceeding_types.json ← generator output: all UPC proceeding types
|
||||
trigger_events.json ← generator output: UPC-referenced trigger events
|
||||
holidays.json ← generator output: DE + UPC regime holidays
|
||||
courts.json ← generator output: UPC courts
|
||||
meta.json ← generator output: {version, generated_at, paliad_commit, source_db_label}
|
||||
|
||||
cmd/gen-upc-snapshot/
|
||||
main.go ← generator entry point
|
||||
README.md ← operator runbook
|
||||
```
|
||||
|
||||
`pkg/litigationplanner/embedded/upc` is the public consumer surface. youpc.org imports it as:
|
||||
|
||||
```go
|
||||
import upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"
|
||||
|
||||
cat, _ := upc.NewCatalog()
|
||||
hc, _ := upc.NewHolidayCalendar()
|
||||
cr, _ := upc.NewCourtRegistry()
|
||||
|
||||
timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26", lp.CalcOptions{...}, cat, hc, cr)
|
||||
```
|
||||
|
||||
### §19.4 Snapshot data shape
|
||||
|
||||
The five data files (`rules.json`, `proceeding_types.json`, `trigger_events.json`, `holidays.json`, `courts.json`) are each a top-level JSON array of the corresponding type. The package's `Rule` / `ProceedingType` / `TriggerEvent` structs deserialise directly (their `json:` tags align with paliad's wire shape).
|
||||
|
||||
`holidays.json` and `courts.json` use minimal structures defined in the embedded sub-package (the package's core API only requires `HolidayCalendar` / `CourtRegistry` interfaces — no struct contract).
|
||||
|
||||
`meta.json` carries the versioning block:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2026-05-26-1",
|
||||
"generated_at": "2026-05-26T15:01:00Z",
|
||||
"paliad_commit": "932b177",
|
||||
"source_db_label": "paliad-dev-supabase",
|
||||
"rule_count": 81,
|
||||
"proceeding_count": 9,
|
||||
"trigger_event_count": 2,
|
||||
"holiday_count": 142,
|
||||
"court_count": 18
|
||||
}
|
||||
```
|
||||
|
||||
`version` uses a date-stamped scheme (`YYYY-MM-DD-N` where N starts at 1 and increments for same-day regenerations) — simple, sortable, no merge conflicts on regen.
|
||||
|
||||
### §19.5 Generator
|
||||
|
||||
`cmd/gen-upc-snapshot/main.go` runs as:
|
||||
|
||||
```sh
|
||||
DATABASE_URL=postgres://... \
|
||||
go run ./cmd/gen-upc-snapshot \
|
||||
-output ./pkg/litigationplanner/embedded/upc
|
||||
```
|
||||
|
||||
Flow:
|
||||
1. Connect to `DATABASE_URL` (paliad's live DB).
|
||||
2. Apply migrations first (`db.ApplyMigrations(url)`) — ensures the snapshot matches schema HEAD.
|
||||
3. SELECT all `paliad.proceeding_types` WHERE `jurisdiction = 'UPC'` AND `is_active = true`. (After B1 the unified `upc.apl` is the only appeal proceeding — the 3 archived old codes are filtered out.)
|
||||
4. SELECT all `paliad.deadline_rules` for those proceeding ids WHERE `lifecycle_state = 'published'` AND `is_active = true`.
|
||||
5. SELECT `paliad.trigger_events` referenced by any rule's `trigger_event_id`.
|
||||
6. SELECT `paliad.holidays` filtered to `country = 'DE' OR regime = 'UPC'` (the union UPC procedures need).
|
||||
7. SELECT `paliad.courts` filtered to `regime = 'UPC' OR court_type LIKE 'upc%'` (UPC court hierarchy).
|
||||
8. Write each result set to `<output>/<name>.json` (pretty-printed for diff-friendliness).
|
||||
9. Compute meta — current paliad commit (via `git rev-parse --short HEAD`), timestamp, row counts.
|
||||
10. Write `meta.json`.
|
||||
|
||||
**Versioning rule**: the generator never overwrites a meta.json with `version` equal to an existing one. If today's date is already used (suffix `-1`), the generator bumps to `-2`. This keeps regenerations within a day distinguishable. Operator can pass `-version <string>` to override.
|
||||
|
||||
### §19.6 Regeneration trigger
|
||||
|
||||
Manual. Three entry points:
|
||||
|
||||
- **`make snapshot-upc`** — Make target invokes the generator with `DATABASE_URL` from env. Documented in `cmd/gen-upc-snapshot/README.md`.
|
||||
- **`go generate ./pkg/litigationplanner/embedded/upc`** — `//go:generate` directive on a stub in the package. Same effect; lets contributors discover the regen path from the package they're modifying.
|
||||
- **Operator runs the command directly** — power-user path.
|
||||
|
||||
**No CI regeneration in v1.** The snapshot is operator-controlled. Future slice can add a nightly CI job that opens a PR with the regenerated snapshot if drift is detected (out of scope here).
|
||||
|
||||
### §19.7 SnapshotCatalog implementation
|
||||
|
||||
In `pkg/litigationplanner/embedded/upc/snapshot.go`:
|
||||
|
||||
```go
|
||||
type SnapshotCatalog struct {
|
||||
proceedings []litigationplanner.ProceedingType
|
||||
rules []litigationplanner.Rule
|
||||
triggerEvents map[int64]litigationplanner.TriggerEvent
|
||||
rulesByProc map[int][]litigationplanner.Rule // for LoadProceeding
|
||||
rulesByID map[uuid.UUID]litigationplanner.Rule
|
||||
procByID map[int]litigationplanner.ProceedingType
|
||||
procByCode map[string]litigationplanner.ProceedingType
|
||||
}
|
||||
|
||||
func NewCatalog() (*SnapshotCatalog, error) // parses embedded JSON
|
||||
```
|
||||
|
||||
All 7 Catalog interface methods (`LoadProceeding`, `LoadProceedingByID`, `LoadRuleByID`, `LoadRuleByCode`, `LoadRulesByTriggerEvent`, `LoadTriggerEventsByIDs`, `LookupEvents`) implemented against the in-memory maps. Lookup methods are O(1) on the indexed maps; `LookupEvents` does a linear scan of `rules` (the UPC subset is < 100 rows; no index needed).
|
||||
|
||||
`ProjectHint` is ignored on the snapshot side (youpc.org has no projects). `applies_to_target` filter for B1 works identically — the rules carry the same array.
|
||||
|
||||
`HolidayCalendar` impl mirrors paliad's `HolidayService` but reads from the embedded holiday slice instead of paliad.holidays. Same `AdjustForNonWorkingDaysWithReason` semantics.
|
||||
|
||||
`CourtRegistry` impl mirrors `CourtService.CountryRegime`. UPC courts only.
|
||||
|
||||
### §19.8 Tests
|
||||
|
||||
`snapshot_test.go` exercises:
|
||||
- Snapshot loads without error
|
||||
- `meta.json` parses + has non-zero counts
|
||||
- `LoadProceeding(ctx, "upc.inf.cfi", ProjectHint{})` returns the expected proceeding + > 0 rules
|
||||
- `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all rules
|
||||
- A golden compute: `Calculate(ctx, "upc.inf.cfi", "2026-01-15", CalcOptions{}, cat, hc, cr)` produces a non-empty timeline with a known root rule (Klageerhebung)
|
||||
|
||||
All tests run without a DB (zero `os.Getenv("TEST_DATABASE_URL")` checks).
|
||||
|
||||
### §19.9 Acceptance criteria
|
||||
|
||||
1. `cmd/gen-upc-snapshot` exists + builds + runs against the live paliad DB.
|
||||
2. `pkg/litigationplanner/embedded/upc/*.json` checked in with the first generated snapshot.
|
||||
3. `embedded/upc.NewCatalog()` (+ `NewHolidayCalendar` + `NewCourtRegistry`) return ready-to-use implementations of the package interfaces.
|
||||
4. Unit tests in `embedded/upc` pass without `TEST_DATABASE_URL` (no DB roundtrip).
|
||||
5. `make snapshot-upc` regenerates the snapshot.
|
||||
6. `go build ./...` + `go test ./...` all green.
|
||||
|
||||
### §19.10 Out of scope (deferred to follow-up)
|
||||
|
||||
- Snapshot signing / integrity attestation. v1 is plain JSON; future slice can ship a `meta.sig` next to `meta.json` for tamper detection.
|
||||
- DE/EPA/DPMA snapshots. v1 only ships the UPC subset (matches youpc.org's scope). Future jurisdictions add as sibling packages: `embedded/de`, `embedded/epa`, etc.
|
||||
- CI regeneration cron. Operator-driven only in v1.
|
||||
- Snapshot diff tooling. v1 relies on `git diff` of the JSON files.
|
||||
|
||||
---
|
||||
|
||||
*End of design doc.*
|
||||
|
||||
@@ -237,6 +237,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.disc.cfi": "Bucheinsicht",
|
||||
"deadlines.upc.apl.cost": "Berufung Kosten",
|
||||
"deadlines.upc.apl.order": "Berufung Anordnungen",
|
||||
"deadlines.upc.apl.unified": "Berufung",
|
||||
"deadlines.appeal_target.label": "Worauf richtet sich die Berufung?",
|
||||
"deadlines.appeal_target.endentscheidung": "Endentscheidung",
|
||||
"deadlines.appeal_target.kostenentscheidung": "Kostenentscheidung",
|
||||
"deadlines.appeal_target.anordnung": "Anordnung",
|
||||
"deadlines.appeal_target.schadensbemessung": "Schadensbemessung",
|
||||
"deadlines.appeal_target.bucheinsicht": "Bucheinsicht",
|
||||
"deadlines.de.group.inf": "Verletzungsverfahren",
|
||||
"deadlines.de.group.null": "Nichtigkeitsverfahren",
|
||||
"deadlines.de.inf.lg": "LG (1. Instanz)",
|
||||
@@ -455,10 +462,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.from_project": "Aus Akte:",
|
||||
"deadlines.side.override": "Andere Seite wählen",
|
||||
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -3327,6 +3330,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.upc.dmgs.cfi": "Damages Determination",
|
||||
"deadlines.upc.disc.cfi": "Lay-open Books",
|
||||
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
|
||||
"deadlines.upc.apl.unified": "Appeal",
|
||||
"deadlines.appeal_target.label": "Appeal against:",
|
||||
"deadlines.appeal_target.endentscheidung": "Final Decision",
|
||||
"deadlines.appeal_target.kostenentscheidung": "Cost Decision",
|
||||
"deadlines.appeal_target.anordnung": "Order",
|
||||
"deadlines.appeal_target.schadensbemessung": "Damages Determination",
|
||||
"deadlines.appeal_target.bucheinsicht": "Lay-open Books",
|
||||
"deadlines.upc.apl.order": "Order Appeal (15-day)",
|
||||
"deadlines.de.group.inf": "Infringement proceedings",
|
||||
"deadlines.de.group.null": "Nullity proceedings",
|
||||
@@ -3553,10 +3563,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.from_project": "From case:",
|
||||
"deadlines.side.override": "Choose other side",
|
||||
"deadlines.side.hint": "Pick a side to focus the columns.",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
|
||||
@@ -32,18 +32,20 @@ import {
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
|
||||
// view is shareable and survives reload:
|
||||
// ?side=claimant|defendant → swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
// ?appellant=claimant|defendant → collapses party=both rows into the
|
||||
// appellant's column (no mirror).
|
||||
// Only meaningful for role-swap
|
||||
// proceedings (Appeal etc.). Default
|
||||
// null = legacy mirror behaviour.
|
||||
// Perspective state. URL-driven so the view is shareable + survives
|
||||
// reload:
|
||||
// ?side=claimant|defendant — swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
//
|
||||
// t-paliad-301 / m/paliad#132 collapsed the duplicate ?side= +
|
||||
// ?appellant= selectors into the single proactive-side picker above.
|
||||
// For role-swap proceedings (Appeal / EPA Opposition / DE Revision /
|
||||
// DPMA Appeal) the picker's labels swap to per-proceeding role
|
||||
// strings (Berufungskläger / Berufungsbeklagter, …) via ROLE_LABELS
|
||||
// below — but the underlying claimant/defendant value the engine
|
||||
// consumes is unchanged.
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the
|
||||
// page is opened with ?project=<id> and that project has our_side set,
|
||||
@@ -52,21 +54,15 @@ let currentAppellant: Side = null;
|
||||
// link, which clears this flag (radio cluster takes over again).
|
||||
let sidePrefilledFromProject = false;
|
||||
|
||||
// Proceedings where one party initiates and "both" rows are role-swap
|
||||
// (i.e. either party files depending on who acted at the lower
|
||||
// instance). For these proceedings the appellant selector is meaningful
|
||||
// — when set, "both" rows collapse to a single row in the appellant's
|
||||
// column. For first-instance proceedings (Inf, Rev, …) the selector is
|
||||
// hidden because there's no appellant axis.
|
||||
//
|
||||
// Today: every upc.apl.* family member plus dpma.appeal.* and
|
||||
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
|
||||
// Conservative — false negatives just hide a control; false positives
|
||||
// would show an irrelevant control.
|
||||
// Role-swap proceedings — the side picker doubles as the appellant
|
||||
// axis. After t-paliad-301 collapsed the duplicate selectors, the
|
||||
// engine reads "appellant" from the single side value for these
|
||||
// proceedings (so a row with primary_party=both renders only in the
|
||||
// chosen side's column). For first-instance proceedings (Inf, Rev,
|
||||
// …) the side picker still narrows columns but doesn't collapse
|
||||
// the "both" rows.
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"upc.apl.unified",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
@@ -75,6 +71,67 @@ const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"epa.opp.boa",
|
||||
]);
|
||||
|
||||
// Per-proceeding role labels (t-paliad-301 / m/paliad#132 Bug A).
|
||||
// Mirrors paliad.proceeding_types.role_*_label_* — the canonical
|
||||
// definition lives in the DB; this map is the frontend's view of
|
||||
// it. Proceedings absent from the map fall back to the generic
|
||||
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
|
||||
//
|
||||
// Keep in sync with mig 137's backfill. Adding a row here without a
|
||||
// matching DB row is fine (the DB col is NULL → still falls back to
|
||||
// default; UI shows the override). Adding to the DB without here
|
||||
// means the UI uses defaults — harmless but inconsistent.
|
||||
type RoleLabels = { proDE: string; reDE: string; proEN: string; reEN: string };
|
||||
const ROLE_LABELS: Record<string, RoleLabels> = {
|
||||
"upc.apl.unified": {
|
||||
proDE: "Berufungskläger",
|
||||
reDE: "Berufungsbeklagter",
|
||||
proEN: "Appellant",
|
||||
reEN: "Appellee",
|
||||
},
|
||||
"upc.rev.cfi": {
|
||||
proDE: "Antragsteller (Nichtigkeit)",
|
||||
reDE: "Antragsgegner (Nichtigkeit)",
|
||||
proEN: "Revocation claimant",
|
||||
reEN: "Revocation defendant",
|
||||
},
|
||||
"epa.opp.opd": {
|
||||
proDE: "Einsprechende(r)",
|
||||
reDE: "Patentinhaber(in)",
|
||||
proEN: "Opponent",
|
||||
reEN: "Patentee",
|
||||
},
|
||||
"epa.opp.boa": {
|
||||
proDE: "Einsprechende(r)",
|
||||
reDE: "Patentinhaber(in)",
|
||||
proEN: "Opponent",
|
||||
reEN: "Patentee",
|
||||
},
|
||||
};
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||
// Proceedings that surface the appeal-target chip group. Currently
|
||||
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
|
||||
// can opt in by adding the code here.
|
||||
const APPEAL_TARGET_PROCEEDINGS = new Set([
|
||||
"upc.apl.unified",
|
||||
]);
|
||||
|
||||
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
|
||||
// in sync with pkg/litigationplanner/types.go AppealTargets).
|
||||
const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
function hasAppealTarget(proceedingType: string): boolean {
|
||||
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
@@ -84,11 +141,6 @@ function readSideFromURL(): Side {
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
@@ -96,13 +148,59 @@ function writeSideToURL(s: Side) {
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
// t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row
|
||||
// radio labels for the currently selected proceeding. Proceedings
|
||||
// without an entry fall back to the existing
|
||||
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
|
||||
function applyRoleLabels(proceedingType: string) {
|
||||
const lang = getLang() === "en" ? "en" : "de";
|
||||
const claimantSpan = document.querySelector<HTMLElement>(
|
||||
"input[type=radio][name=side][value=claimant] + span"
|
||||
);
|
||||
const defendantSpan = document.querySelector<HTMLElement>(
|
||||
"input[type=radio][name=side][value=defendant] + span"
|
||||
);
|
||||
if (!claimantSpan || !defendantSpan) return;
|
||||
|
||||
const labels = ROLE_LABELS[proceedingType];
|
||||
if (labels) {
|
||||
claimantSpan.textContent = lang === "en" ? labels.proEN : labels.proDE;
|
||||
defendantSpan.textContent = lang === "en" ? labels.reEN : labels.reDE;
|
||||
} else {
|
||||
// Default — let i18n drive via data-i18n attribute. Reset to the
|
||||
// canonical i18n value so a previous override doesn't stick when
|
||||
// switching from upc.apl.unified back to upc.inf.cfi.
|
||||
claimantSpan.textContent = t("deadlines.side.claimant");
|
||||
defendantSpan.textContent = t("deadlines.side.defendant");
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B1 — appeal-target URL state. Empty string = no target picked
|
||||
// (the row is hidden because the proceeding isn't an appeal). Any
|
||||
// other value must be one of APPEAL_TARGETS; unknown values are
|
||||
// rejected by readAppealTargetFromURL so a stale link can't break
|
||||
// the engine filter.
|
||||
function readAppealTargetFromURL(): AppealTarget {
|
||||
const raw = new URLSearchParams(window.location.search).get("target") || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function writeAppealTargetToURL(t: AppealTarget) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
if (t === "") url.searchParams.delete("target");
|
||||
else url.searchParams.set("target", t);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Default target on first picker entry into upc.apl. m: Endentscheidung
|
||||
// is the most-common appeal target; the chip group also defaults
|
||||
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
|
||||
// sync so the URL-less default render hits the same code path.
|
||||
let currentAppealTarget: AppealTarget = "";
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
@@ -268,6 +366,13 @@ async function doCalc() {
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung,
|
||||
// default to "endentscheidung" when no chip pick is stored in URL.
|
||||
// For non-appeal proceedings the engine ignores opts.AppealTarget.
|
||||
const appealTarget = hasAppealTarget(selectedType)
|
||||
? (currentAppealTarget || "endentscheidung")
|
||||
: "";
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
@@ -276,6 +381,7 @@ async function doCalc() {
|
||||
courtId,
|
||||
perCardChoices,
|
||||
includeHidden: showHidden,
|
||||
appealTarget,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
@@ -377,7 +483,12 @@ function renderResults(data: DeadlineResponse) {
|
||||
editable: true,
|
||||
showNotes,
|
||||
side: currentSide,
|
||||
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
|
||||
// t-paliad-301: the appellant axis collapses into the single
|
||||
// side picker. For role-swap proceedings, currentSide IS the
|
||||
// appellant pick (so a row with primary_party=both renders only
|
||||
// in the picked side's column). For non-role-swap proceedings,
|
||||
// the appellant axis is irrelevant — pass null.
|
||||
appellant: hasAppellantAxis(selectedType) ? currentSide : null,
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
@@ -446,7 +557,8 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
syncAppealTargetRowVisibility();
|
||||
applyRoleLabels(selectedType);
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
@@ -454,20 +566,20 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
// syncAppellantRowVisibility hides the appellant selector for
|
||||
// proceedings that have no appellant axis (first-instance Inf, Rev,
|
||||
// …). Clears the in-memory state and the URL param when hidden so a
|
||||
// shared link with ?appellant= doesn't leak into an unrelated
|
||||
// proceeding's render.
|
||||
function syncAppellantRowVisibility() {
|
||||
const row = document.getElementById("appellant-row");
|
||||
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||
// syncAppealTargetRowVisibility shows the appeal-target chip group
|
||||
// when the unified upc.apl Berufung tile is selected, hides it
|
||||
// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears
|
||||
// state + URL when hiding so a stale ?target= can't leak.
|
||||
function syncAppealTargetRowVisibility() {
|
||||
const row = document.getElementById("appeal-target-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
const visible = hasAppealTarget(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
if (!visible && currentAppealTarget !== "") {
|
||||
currentAppealTarget = "";
|
||||
writeAppealTargetToURL("");
|
||||
syncRadioGroup("appeal-target", "endentscheidung");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,9 +766,9 @@ function initViewToggle() {
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
currentAppealTarget = readAppealTargetFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
|
||||
syncSideHintVisibility();
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
@@ -670,13 +782,20 @@ function initPerspectiveControls() {
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
|
||||
// Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
|
||||
// Each chip change re-fetches with the new target slug so the
|
||||
// timeline re-renders against the matching rule subset.
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appeal-target]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(v)) {
|
||||
currentAppealTarget = v as AppealTarget;
|
||||
} else {
|
||||
currentAppealTarget = "";
|
||||
}
|
||||
writeAppealTargetToURL(currentAppealTarget);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,6 +195,12 @@ export interface CalcParams {
|
||||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||||
// ON.
|
||||
includeHidden?: boolean;
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
|
||||
// (upc.apl) timeline to the rule subset whose applies_to_target
|
||||
// contains the requested slug. Empty = no filter. Valid values:
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -811,6 +817,7 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
? params.perCardChoices
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
appealTarget: params.appealTarget || undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -1184,10 +1184,12 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.appeal_target.anordnung"
|
||||
| "deadlines.appeal_target.bucheinsicht"
|
||||
| "deadlines.appeal_target.endentscheidung"
|
||||
| "deadlines.appeal_target.kostenentscheidung"
|
||||
| "deadlines.appeal_target.label"
|
||||
| "deadlines.appeal_target.schadensbemessung"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -1514,6 +1516,7 @@ export type I18nKey =
|
||||
| "deadlines.upc.apl.cost"
|
||||
| "deadlines.upc.apl.merits"
|
||||
| "deadlines.upc.apl.order"
|
||||
| "deadlines.upc.apl.unified"
|
||||
| "deadlines.upc.ccr.cfi"
|
||||
| "deadlines.upc.disc.cfi"
|
||||
| "deadlines.upc.dmgs.cfi"
|
||||
|
||||
@@ -28,16 +28,20 @@ function proceedingBtn(p: ProceedingDef): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
|
||||
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
|
||||
// unified "Berufung" tile (upc.apl). After picking it, the user
|
||||
// selects which decision the appeal is directed AT via the
|
||||
// .appeal-target-row chip group below — the engine then filters
|
||||
// rules whose applies_to_target contains the picked slug.
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
@@ -216,20 +220,33 @@ export function renderVerfahrensablauf(): string {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
{/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1).
|
||||
Shown only when the unified upc.apl Berufung tile is
|
||||
selected; lets the user narrow the timeline to the
|
||||
rules whose applies_to_target contains the picked
|
||||
decision kind. URL state ?target=<slug>. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
<input type="radio" name="appeal-target" value="endentscheidung" checked />
|
||||
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
<input type="radio" name="appeal-target" value="kostenentscheidung" />
|
||||
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
<input type="radio" name="appeal-target" value="anordnung" />
|
||||
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="schadensbemessung" />
|
||||
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="bucheinsicht" />
|
||||
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
134
internal/db/migration_136_test.go
Normal file
134
internal/db/migration_136_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Slice B.1 (t-paliad-273) — migration 136 backfill invariants.
|
||||
//
|
||||
// The dry-run gate (migrate_test.go: TestMigrations_DryRun) catches
|
||||
// migrations that crash on apply, but it rolls back inside its own
|
||||
// transaction — the post-state assertions in mig 136's PL/pgSQL block
|
||||
// run, but a future refactor of those assertions might forget a check
|
||||
// or introduce a silent count drift. This test layers a Go-side
|
||||
// invariant check on top so the contract is restated in test code,
|
||||
// outside the PL/pgSQL block, against the resulting tables.
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL, same pattern as
|
||||
// internal/services/submission_codes_shape_test.go.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// TestMigration136_BackfillInvariants applies every embedded migration
|
||||
// (which lands mig 136 along the way) and then asserts the four
|
||||
// invariants the B.1 design + B.0 findings nailed down:
|
||||
//
|
||||
// 1. procedural_events row count = (distinct submission_codes in
|
||||
// deadline_rules) + (deadline_rules with NULL submission_code).
|
||||
// Codes-bearing branch is 1:1 per the B.0 audit (no multi-row
|
||||
// codes since the _archived_litigation.* removal); the NULL
|
||||
// branch gets one synthetic procedural_event per rule.
|
||||
// 2. sequencing_rules row count = deadline_rules row count (1:1).
|
||||
// 3. legal_sources row count = distinct legal_source in
|
||||
// deadline_rules (NULL excluded).
|
||||
// 4. every sequencing_rules row's procedural_event_id resolves to a
|
||||
// procedural_events row (NOT NULL FK already enforces this at the
|
||||
// DB level — this test catches a future relaxation of the FK).
|
||||
// 5. no two synthetic codes collide (covered by the UNIQUE on
|
||||
// procedural_events.code; restated here for documentation).
|
||||
//
|
||||
// The test is robust against corpus size — it derives all expected
|
||||
// counts from the live deadline_rules state, so a scratch DB with 0
|
||||
// rules trivially passes, and a prod-shaped scratch DB exercises the
|
||||
// real invariants.
|
||||
func TestMigration136_BackfillInvariants(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping mig 136 invariant test")
|
||||
}
|
||||
if err := ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
var (
|
||||
drTotal, drCodesDistinct, drCodesNull, drLegalDistinct int
|
||||
peTotal, srTotal, lsTotal int
|
||||
orphanPE, dupSynthetic int
|
||||
)
|
||||
|
||||
mustQ := func(label, q string, dst *int) {
|
||||
t.Helper()
|
||||
if err := conn.QueryRowContext(ctx, q).Scan(dst); err != nil {
|
||||
t.Fatalf("%s: %v", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
mustQ("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &drTotal)
|
||||
mustQ("dr_codes_distinct",
|
||||
`SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL`,
|
||||
&drCodesDistinct)
|
||||
mustQ("dr_codes_null",
|
||||
`SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL`,
|
||||
&drCodesNull)
|
||||
mustQ("dr_legal_distinct",
|
||||
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
|
||||
&drLegalDistinct)
|
||||
mustQ("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &peTotal)
|
||||
mustQ("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &srTotal)
|
||||
mustQ("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &lsTotal)
|
||||
|
||||
// Invariant 1: procedural_events = distinct_codes + null_codes
|
||||
wantPE := drCodesDistinct + drCodesNull
|
||||
if peTotal != wantPE {
|
||||
t.Errorf("procedural_events count mismatch: got %d, want %d (distinct codes=%d + null-code rules=%d)",
|
||||
peTotal, wantPE, drCodesDistinct, drCodesNull)
|
||||
}
|
||||
|
||||
// Invariant 2: sequencing_rules 1:1 with deadline_rules
|
||||
if srTotal != drTotal {
|
||||
t.Errorf("sequencing_rules count mismatch: got %d, want %d (1:1 with deadline_rules)",
|
||||
srTotal, drTotal)
|
||||
}
|
||||
|
||||
// Invariant 3: legal_sources = distinct legal_source
|
||||
if lsTotal != drLegalDistinct {
|
||||
t.Errorf("legal_sources count mismatch: got %d, want %d (distinct legal_source)",
|
||||
lsTotal, drLegalDistinct)
|
||||
}
|
||||
|
||||
// Invariant 4: every sequencing_rules.procedural_event_id resolves
|
||||
mustQ("orphan_pe", `
|
||||
SELECT COUNT(*)
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.id IS NULL`, &orphanPE)
|
||||
if orphanPE != 0 {
|
||||
t.Errorf("FK integrity violated: %d sequencing_rules row(s) have no resolving procedural_event_id", orphanPE)
|
||||
}
|
||||
|
||||
// Invariant 5: no duplicate synthetic codes
|
||||
mustQ("dup_synthetic", `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT code FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY code
|
||||
HAVING COUNT(*) > 1
|
||||
) d`, &dupSynthetic)
|
||||
if dupSynthetic != 0 {
|
||||
t.Errorf("synthetic code uniqueness violated: %d duplicate(s) under 'null.%%' prefix", dupSynthetic)
|
||||
}
|
||||
|
||||
t.Logf("mig 136 invariants OK: deadline_rules=%d, procedural_events=%d (=%d+%d), "+
|
||||
"sequencing_rules=%d, legal_sources=%d (distinct legal_source=%d)",
|
||||
drTotal, peTotal, drCodesDistinct, drCodesNull, srTotal, lsTotal, drLegalDistinct)
|
||||
}
|
||||
72
internal/db/migrations/134_berufung_unification.down.sql
Normal file
72
internal/db/migrations/134_berufung_unification.down.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
-- 134_berufung_unification — DOWN
|
||||
--
|
||||
-- Reverses the Berufung unification: un-archives the 3 old appeal
|
||||
-- proceeding_types, points the 16 rules back at their original
|
||||
-- proceeding by their applies_to_target stamp, drops the new
|
||||
-- upc.apl row, drops the two columns + their CHECK constraints.
|
||||
--
|
||||
-- The 3 old proceeding_types are recovered by code (we archived them,
|
||||
-- never deleted them — that's what makes this down-migration safe).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger — step 2 UPDATEs
|
||||
-- paliad.deadline_rules to reverse the reassignment).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 134 DOWN: revert Slice B1 — restore 3 separate UPC appeal proceeding_types, drop applies_to_target column',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Un-archive the 3 old appeal proceeding_types.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = true
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Point rules back at their original proceeding_type by stamp.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.merits'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['endentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.cost'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['kostenentscheidung']::text[];
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.order'
|
||||
)
|
||||
WHERE dr.applies_to_target = ARRAY['anordnung']::text[];
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Drop the unified upc.apl.unified row (now orphaned).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DELETE FROM paliad.proceeding_types WHERE code = 'upc.apl.unified';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Drop the new columns + their CHECK constraints.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_applies_to_target_chk;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS applies_to_target;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP CONSTRAINT IF EXISTS proceeding_types_appeal_target_chk;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS appeal_target;
|
||||
272
internal/db/migrations/134_berufung_unification.up.sql
Normal file
272
internal/db/migrations/134_berufung_unification.up.sql
Normal file
@@ -0,0 +1,272 @@
|
||||
-- 134_berufung_unification — Slice B1, m/paliad#124, t-paliad-298+
|
||||
--
|
||||
-- Collapses the 3 active UPC appeal proceeding_types (upc.apl.merits,
|
||||
-- upc.apl.cost, upc.apl.order — 16 rules across 3 codes) into ONE
|
||||
-- unified upc.apl proceeding type + an `appeal_target` discriminator on
|
||||
-- both proceeding_types (top-level marker) and deadline_rules
|
||||
-- (per-row applies-to set, text[] for multi-target rules).
|
||||
--
|
||||
-- ADDITIVE ONLY. The migration:
|
||||
-- 1. Adds the two columns + check constraints.
|
||||
-- 2. Inserts the new upc.apl proceeding type.
|
||||
-- 3. Audit-first: NOTICES every row about to be touched.
|
||||
-- 4. Reassigns rule rows from the 3 old types to upc.apl, stamping
|
||||
-- applies_to_target by source proceeding code.
|
||||
-- 5. Archives (is_active=false) the 3 old proceeding_types — NEVER
|
||||
-- deletes them, so any historical project_event_choices / FK
|
||||
-- references stay intact.
|
||||
--
|
||||
-- Schadensbemessung + Bucheinsicht get NO rule rows in this migration
|
||||
-- (m's 2026-05-26 decision: distinct rule sets, not shared with
|
||||
-- merits). Their appeal_target enum values are defined and addressable
|
||||
-- by CalcOptions.AppealTarget; the engine returns an empty timeline
|
||||
-- until rules are seeded in a follow-up slice (likely via
|
||||
-- /admin/rules, pairing with t-paliad-193 orphan-concept-seed).
|
||||
--
|
||||
-- See docs/design-litigation-planner-2026-05-26.md §18.1.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules — step 4 reassigns 16 rules).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 134: t-paliad-292 Slice B1 — Berufung unification, collapse 3 UPC appeal proceeding_types into upc.apl.unified + appeal_target discriminator',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Schema additions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN appeal_target text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD CONSTRAINT proceeding_types_appeal_target_chk
|
||||
CHECK (appeal_target IS NULL OR appeal_target IN (
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.appeal_target IS
|
||||
'Top-level appeal-target marker. NULL on non-appeal proceedings. '
|
||||
'Reserved for future variants — today only the unified upc.apl row '
|
||||
'has this NULL (the actual per-rule target set lives on '
|
||||
'paliad.deadline_rules.applies_to_target).';
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN applies_to_target text[] NULL;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_applies_to_target_chk
|
||||
CHECK (
|
||||
applies_to_target IS NULL
|
||||
OR applies_to_target <@ ARRAY[
|
||||
'endentscheidung',
|
||||
'kostenentscheidung',
|
||||
'anordnung',
|
||||
'schadensbemessung',
|
||||
'bucheinsicht'
|
||||
]::text[]
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.applies_to_target IS
|
||||
'Set of appeal_target slugs this rule applies to. NULL on rules '
|
||||
'that don''t belong to an appeal proceeding. The engine filters '
|
||||
'by CalcOptions.AppealTarget — rules whose applies_to_target '
|
||||
'contains the requested slug are emitted; others are suppressed.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Insert the unified upc.apl row.
|
||||
--
|
||||
-- Inherits default_color from the merits row (the most-used appeal
|
||||
-- track today). sort_order follows the cluster of UPC proceedings;
|
||||
-- placed just before upc.apl.merits's old slot so the chip-grouped
|
||||
-- picker UI lands Berufung in a sensible position. Tweakable later
|
||||
-- without a migration.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.proceeding_types (
|
||||
code, name, name_en, description, jurisdiction, category,
|
||||
default_color, sort_order, is_active, display_order,
|
||||
appeal_target
|
||||
)
|
||||
SELECT
|
||||
'upc.apl.unified',
|
||||
'Berufungsverfahren',
|
||||
'Appeal',
|
||||
'Vereinheitlichtes Berufungsverfahren — wählen Sie anschließend, '
|
||||
'worauf die Berufung sich richtet (Endentscheidung, '
|
||||
'Kostenentscheidung, Anordnung, Schadensbemessung, Bucheinsicht).',
|
||||
'UPC',
|
||||
'fristenrechner',
|
||||
default_color,
|
||||
sort_order,
|
||||
true,
|
||||
display_order,
|
||||
NULL
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.merits';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Audit-first RAISE NOTICE pass.
|
||||
--
|
||||
-- Lists every rule row that will be reassigned + every proceeding_type
|
||||
-- row that will be archived. The migration runs to completion either
|
||||
-- way; the operator reads the notices to confirm scope before the
|
||||
-- next migration in the chain.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
upc_apl_id int;
|
||||
rules_touched int := 0;
|
||||
procs_archived int := 0;
|
||||
BEGIN
|
||||
SELECT id INTO upc_apl_id
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.unified';
|
||||
RAISE NOTICE '[mig 134] new upc.apl.unified proceeding_type_id = %', upc_apl_id;
|
||||
|
||||
RAISE NOTICE '[mig 134] Rules to reassign to upc.apl.unified with applies_to_target:';
|
||||
FOR rec IN
|
||||
SELECT dr.id AS rule_id,
|
||||
pt.code AS old_proceeding,
|
||||
dr.submission_code,
|
||||
dr.name
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND dr.is_active = true
|
||||
ORDER BY pt.code, dr.sequence_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % % (%)',
|
||||
rec.old_proceeding, rec.submission_code, rec.name, rec.rule_id;
|
||||
rules_touched := rules_touched + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total rules to reassign: %', rules_touched;
|
||||
|
||||
RAISE NOTICE '[mig 134] Proceeding_types to archive (is_active=false):';
|
||||
FOR rec IN
|
||||
SELECT id, code, name
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
ORDER BY sort_order
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 134] % % (id=%)', rec.code, rec.name, rec.id;
|
||||
procs_archived := procs_archived + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 134] Total proceeding_types to archive: %', procs_archived;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Reassign rule rows.
|
||||
--
|
||||
-- Stamp applies_to_target by source proceeding code, then point all
|
||||
-- 16 rules at the new upc.apl row.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 4a. upc.apl.merits → applies_to_target = {endentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['endentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.merits'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4b. upc.apl.cost → applies_to_target = {kostenentscheidung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['kostenentscheidung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.cost'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4c. upc.apl.order → applies_to_target = {anordnung}
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = ARRAY['anordnung']::text[]
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.order'
|
||||
AND dr.is_active = true;
|
||||
|
||||
-- 4d. Reassign all 16 rules to the new upc.apl.unified proceeding_type row.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET proceeding_type_id = (
|
||||
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.unified'
|
||||
)
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. Archive the 3 old proceeding_types.
|
||||
--
|
||||
-- NEVER DELETE — historical project_event_choices and project FKs
|
||||
-- (paliad.projects.proceeding_type_id) may still reference these IDs.
|
||||
-- The is_active=false flag stops them appearing in the picker but
|
||||
-- preserves FK integrity for historical reads.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. Post-migration sanity check.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
unified_count int;
|
||||
archived_count int;
|
||||
target_distribution record;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO unified_count
|
||||
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;
|
||||
RAISE NOTICE '[mig 134] post: rules on unified upc.apl.unified = % (expected 16)', unified_count;
|
||||
IF unified_count <> 16 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 16 rules on upc.apl.unified, got %', unified_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO archived_count
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
|
||||
AND is_active = false;
|
||||
RAISE NOTICE '[mig 134] post: archived old appeal proceeding_types = % (expected 3)', archived_count;
|
||||
IF archived_count <> 3 THEN
|
||||
RAISE EXCEPTION '[mig 134] FAILED — expected 3 archived types, got %', archived_count;
|
||||
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 134] post: applies_to_target=% count=%',
|
||||
target_distribution.target, target_distribution.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- TODO (follow-up slice, not in 134):
|
||||
--
|
||||
-- Seed rules for Schadensbemessung-as-appeal + Bucheinsicht-as-appeal.
|
||||
-- m's 2026-05-26 decision: distinct rule sets, NOT shared with merits.
|
||||
-- - Schadensbemessung: anchor on R.118.4 decision; conjecture 2/4-month
|
||||
-- merits-style track but distinct legal basis.
|
||||
-- - Bucheinsicht: anchor on R.142 (Lay-open-books decision); conjecture
|
||||
-- 15-day track per R.220.2 + R.224.2.b.
|
||||
-- Can pair with t-paliad-193 orphan-concept-seed if m wants a combined
|
||||
-- editorial pass via /admin/rules.
|
||||
-- ---------------------------------------------------------------
|
||||
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 $$;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 136_procedural_events_additive (down) — Slice B.1, t-paliad-273
|
||||
--
|
||||
-- Safe to run at any point in B.1's lifetime. Up does NOT touch
|
||||
-- paliad.deadline_rules, so dropping the new tables + columns loses no
|
||||
-- application data — every source row in deadline_rules is intact and
|
||||
-- authoritative through the dual-write window.
|
||||
--
|
||||
-- Reverse order: drop indexes implicitly via DROP TABLE, drop the two
|
||||
-- deadlines link columns first (their FKs target procedural_events +
|
||||
-- sequencing_rules), then drop the three new tables in FK-safe order
|
||||
-- (sequencing_rules → procedural_events → legal_sources).
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS procedural_event_id,
|
||||
DROP COLUMN IF EXISTS sequencing_rule_id;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules;
|
||||
DROP TABLE IF EXISTS paliad.procedural_events;
|
||||
DROP TABLE IF EXISTS paliad.legal_sources;
|
||||
488
internal/db/migrations/136_procedural_events_additive.up.sql
Normal file
488
internal/db/migrations/136_procedural_events_additive.up.sql
Normal file
@@ -0,0 +1,488 @@
|
||||
-- 136_procedural_events_additive — Slice B.1, t-paliad-273 / m/paliad#93
|
||||
--
|
||||
-- ADDITIVE ONLY. Creates the three new tables that split today's
|
||||
-- paliad.deadline_rules into its three latent concepts (per the
|
||||
-- 2026-05-25 inventor design + 2026-05-26 B.0 re-validation):
|
||||
--
|
||||
-- 1. paliad.legal_sources — the source-of-law citations
|
||||
-- (DE.PatG.102, UPC.RoP.220.1, …)
|
||||
-- 2. paliad.procedural_events — the procedural-event templates
|
||||
-- (Rechtsbeschwerdebegründung, etc.;
|
||||
-- successor of `submission_code`)
|
||||
-- 3. paliad.sequencing_rules — the timing + trigger + condition
|
||||
-- mechanics (today's per-row data)
|
||||
--
|
||||
-- and adds two nullable link columns on paliad.deadlines so B.2's
|
||||
-- dual-write phase has somewhere to point.
|
||||
--
|
||||
-- The migration does NOT touch paliad.deadline_rules. The legacy table
|
||||
-- stays intact and authoritative for reads until B.3 flips the cutover.
|
||||
-- deadlines.rule_id stays in place (read by the calculator + projection
|
||||
-- service). No app code is changed by this migration; B.2 introduces
|
||||
-- the dual-write that wires services to the new tables.
|
||||
--
|
||||
-- Backfill plan (cf. design §5.1 + B.0 findings §7):
|
||||
-- * legal_sources <- DISTINCT legal_source FROM deadline_rules WHERE
|
||||
-- legal_source IS NOT NULL. pretty_de/pretty_en
|
||||
-- LEFT NULL for now (legalSourcePretty() in Go
|
||||
-- continues to materialise them on read; a future
|
||||
-- slice backfills them via a Go shim).
|
||||
-- * procedural_events <-
|
||||
-- (a) DISTINCT ON (submission_code) FROM deadline_rules WHERE
|
||||
-- submission_code IS NOT NULL — picks the lowest-id rule per
|
||||
-- code as the procedural-event identity source.
|
||||
-- (b) one synthetic procedural_event per NULL-submission_code
|
||||
-- rule, code = 'null.' || substring(replace(id::text,'-',''),1,8).
|
||||
-- m's pick (paliadin instruction 2026-05-26): mint synthetic
|
||||
-- codes so every deadline_rules row ends up with a
|
||||
-- procedural_events row, preserving the 1:1 sequencing-rule
|
||||
-- backfill and keeping the NOT NULL FK on
|
||||
-- sequencing_rules.procedural_event_id intact.
|
||||
-- * sequencing_rules <- 1:1 from deadline_rules. The new row inherits
|
||||
-- the source row's id so that any existing
|
||||
-- paliad.deadlines.rule_id FK target stays resolvable through
|
||||
-- the dual-write window (design §5.1 step 4).
|
||||
-- * deadlines.procedural_event_id + sequencing_rule_id <- joined from
|
||||
-- sequencing_rules on the inherited id.
|
||||
--
|
||||
-- Design deviations (intentional, documented):
|
||||
-- - procedural_events.event_kind is NULLABLE (design proposed NOT NULL
|
||||
-- with 'other' fallback). Today 89 deadline_rules rows have NULL
|
||||
-- event_type — these are "structural / parent-only rows in the
|
||||
-- proceeding tree" per B.0 §1. Forcing them to 'other' would lose
|
||||
-- semantics. A later slice can tighten this to NOT NULL after the
|
||||
-- 78+11 NULLs are reclassified.
|
||||
-- - legal_sources.pretty_de / pretty_en are NULLABLE (design proposed
|
||||
-- NOT NULL). Materialising them requires the Go-side
|
||||
-- legalSourcePretty() function — out of scope for a SQL migration.
|
||||
-- The Go read path continues to compute them on the fly from
|
||||
-- legal_source / citation; a future slice (Go shim driven from
|
||||
-- internal/services/submission_vars.go:619) backfills them.
|
||||
-- - submission_drafts is NOT modified. The design proposes adding
|
||||
-- procedural_event_id there too (§4.1 §5.1 step 6) but the B.1
|
||||
-- instruction scope is explicit: tables + deadlines columns only.
|
||||
-- submission_drafts continues to key off submission_code text.
|
||||
--
|
||||
-- Audit pattern follows mig 135 (Slice B3): PRE-pass counts what we
|
||||
-- expect to write, BACKFILL runs the SELECT-INSERTs, POST-pass verifies
|
||||
-- row counts and FK integrity. Any mismatch RAISE EXCEPTIONs and the
|
||||
-- transaction rolls back — operator sees the NOTICE lines and the
|
||||
-- failed assertion message.
|
||||
--
|
||||
-- See: docs/design-procedural-events-model-2026-05-25.md §4 + §5
|
||||
-- docs/design-procedural-events-b0-findings-2026-05-26.md §7
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. PRE pass — snapshot what we're about to backfill
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_rules int;
|
||||
v_codes_nn int;
|
||||
v_codes_distinct int;
|
||||
v_codes_null int;
|
||||
v_legal_distinct int;
|
||||
v_concept_linked int;
|
||||
v_dups int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_rules FROM paliad.deadline_rules;
|
||||
SELECT COUNT(*) INTO v_codes_nn FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(DISTINCT submission_code) INTO v_codes_distinct
|
||||
FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
|
||||
SELECT COUNT(DISTINCT legal_source) INTO v_legal_distinct
|
||||
FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_concept_linked FROM paliad.deadline_rules WHERE concept_id IS NOT NULL;
|
||||
|
||||
RAISE NOTICE '[mig 136] PRE: deadline_rules=%, with_submission_code=%, distinct_codes=%, null_codes=%, distinct_legal_sources=%, concept_linked=%',
|
||||
v_rules, v_codes_nn, v_codes_distinct, v_codes_null, v_legal_distinct, v_concept_linked;
|
||||
|
||||
-- Defensive: refuse to run if multi-row submission_codes have crept
|
||||
-- back in. B.0 (2026-05-26) found zero; mig 134 + 135 do not add
|
||||
-- any. If this CHECK ever fires the backfill arithmetic below
|
||||
-- breaks silently (one PE per code becomes ambiguous), so abort.
|
||||
SELECT COUNT(*) INTO v_dups FROM (
|
||||
SELECT submission_code
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
GROUP BY submission_code
|
||||
HAVING COUNT(*) > 1
|
||||
) d;
|
||||
IF v_dups > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED PRE: % submission_code value(s) appear on >1 deadline_rules row. '
|
||||
'The B.0 audit (2026-05-26) found zero. If you are seeing this, a rule was added that '
|
||||
'duplicates an existing submission_code (or the _archived_litigation.* rows returned). '
|
||||
'Decide whether the new schema collapses them (multiple sequencing rules → one '
|
||||
'procedural event) or whether each row gets its own code, then update this migration '
|
||||
'or the offending data before re-running.', v_dups;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. CREATE TABLE paliad.legal_sources
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.legal_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation text NOT NULL UNIQUE,
|
||||
jurisdiction text NOT NULL,
|
||||
pretty_de text,
|
||||
pretty_en text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.legal_sources IS
|
||||
'Source-of-law citations (DE.PatG.102, UPC.RoP.220.1, …). One row per '
|
||||
'distinct citation shorthand. pretty_de/pretty_en backfilled by a '
|
||||
'future Go-driven slice; until then NULL and the Go service ('
|
||||
'internal/services/submission_vars.go:619 legalSourcePretty) computes '
|
||||
'the human-readable form on read from the citation. Slice B.1 t-paliad-273.';
|
||||
|
||||
CREATE INDEX legal_sources_jurisdiction_idx ON paliad.legal_sources(jurisdiction);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. CREATE TABLE paliad.procedural_events
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.procedural_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
name_en text NOT NULL DEFAULT '',
|
||||
description text,
|
||||
event_kind text,
|
||||
primary_party_default text,
|
||||
legal_source_id uuid REFERENCES paliad.legal_sources(id),
|
||||
concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.procedural_events(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.procedural_events IS
|
||||
'Procedural-event templates — the "what kind of step is this in the '
|
||||
'proceeding" hat of the legacy paliad.deadline_rules row. One row per '
|
||||
'unique submission_code, plus one synthetic row per NULL-submission_code '
|
||||
'rule (code prefix "null."). Slice B.1 t-paliad-273.';
|
||||
|
||||
COMMENT ON COLUMN paliad.procedural_events.event_kind IS
|
||||
'filing|reply|hearing|decision|order|other. NULLABLE for now — 89 '
|
||||
'rules in the live corpus have NULL event_type (structural / parent-only '
|
||||
'rows in the proceeding tree). A future slice can tighten to NOT NULL '
|
||||
'after these are reclassified.';
|
||||
|
||||
COMMENT ON COLUMN paliad.procedural_events.concept_id IS
|
||||
'Optional reference to a deadline_concepts row. N:1 — one concept may '
|
||||
'be shared by many procedural events (e.g. "Berufungsfrist" attaches to '
|
||||
'all four court-specific Berufung procedural events). Do NOT add UNIQUE.';
|
||||
|
||||
CREATE INDEX procedural_events_concept_id_idx ON paliad.procedural_events(concept_id);
|
||||
CREATE INDEX procedural_events_event_kind_idx ON paliad.procedural_events(event_kind);
|
||||
CREATE INDEX procedural_events_lifecycle_idx ON paliad.procedural_events(lifecycle_state);
|
||||
CREATE INDEX procedural_events_legal_source_idx ON paliad.procedural_events(legal_source_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. CREATE TABLE paliad.sequencing_rules
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
|
||||
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
parent_id uuid REFERENCES paliad.sequencing_rules(id),
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id),
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months',
|
||||
timing text DEFAULT 'after',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text,
|
||||
alt_rule_code text,
|
||||
anchor_alt text,
|
||||
combine_op text,
|
||||
condition_expr jsonb,
|
||||
primary_party text,
|
||||
sequence_order integer NOT NULL DEFAULT 0,
|
||||
is_spawn boolean NOT NULL DEFAULT false,
|
||||
spawn_label text,
|
||||
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
is_bilateral boolean NOT NULL DEFAULT false,
|
||||
is_court_set boolean NOT NULL DEFAULT false,
|
||||
priority text NOT NULL DEFAULT 'mandatory',
|
||||
rule_code text,
|
||||
rule_codes text[],
|
||||
deadline_notes text,
|
||||
deadline_notes_en text,
|
||||
choices_offered jsonb,
|
||||
applies_to_target text[],
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.sequencing_rules(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules IS
|
||||
'Sequencing-rule mechanics — the "how and when does this fire" hat of '
|
||||
'the legacy paliad.deadline_rules row. 1:1 with deadline_rules during '
|
||||
'the dual-write window; the id is inherited from deadline_rules.id so '
|
||||
'paliad.deadlines.rule_id FKs continue to resolve transitively. '
|
||||
'Slice B.1 t-paliad-273.';
|
||||
|
||||
COMMENT ON COLUMN paliad.sequencing_rules.primary_party IS
|
||||
'Per-rule override of procedural_events.primary_party_default. Same '
|
||||
'four-value vocab as deadline_rules.primary_party (mig 135 CHECK). '
|
||||
'NULL = use procedural-event default. A future slice can add the '
|
||||
'same CHECK here.';
|
||||
|
||||
CREATE INDEX sequencing_rules_pe_proc_lifecycle_idx
|
||||
ON paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state);
|
||||
CREATE INDEX sequencing_rules_parent_id_idx ON paliad.sequencing_rules(parent_id);
|
||||
CREATE INDEX sequencing_rules_trigger_event_idx ON paliad.sequencing_rules(trigger_event_id);
|
||||
CREATE INDEX sequencing_rules_proceeding_type_idx ON paliad.sequencing_rules(proceeding_type_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. ALTER paliad.deadlines — add link columns
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
|
||||
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.procedural_event_id IS
|
||||
'NULLABLE link to the procedural event this deadline instantiates. '
|
||||
'Added Slice B.1 (mig 136). B.2 dual-write populates it on every new '
|
||||
'deadline; B.3 cutover flips reads to use this instead of rule_id. '
|
||||
'rule_id stays in place until B.4 destructive drop.';
|
||||
COMMENT ON COLUMN paliad.deadlines.sequencing_rule_id IS
|
||||
'NULLABLE link to the sequencing rule. Same lifecycle as '
|
||||
'procedural_event_id — added Slice B.1, dual-written B.2, read in B.3, '
|
||||
'rule_id dropped in B.4.';
|
||||
|
||||
CREATE INDEX deadlines_procedural_event_id_idx ON paliad.deadlines(procedural_event_id);
|
||||
CREATE INDEX deadlines_sequencing_rule_id_idx ON paliad.deadlines(sequencing_rule_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. BACKFILL — legal_sources
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
SELECT DISTINCT
|
||||
legal_source AS citation,
|
||||
COALESCE(NULLIF(split_part(legal_source, '.', 1), ''), 'other') AS jurisdiction
|
||||
FROM paliad.deadline_rules
|
||||
WHERE legal_source IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. BACKFILL — procedural_events
|
||||
-- (a) codes-bearing branch: DISTINCT ON (submission_code) picks the
|
||||
-- lowest-id (tie-break sequence_order) deadline_rules row as the
|
||||
-- identity source per the design's §5.1 step 3.
|
||||
-- (b) NULL-code branch: one synthetic row per rule, code minted from
|
||||
-- the rule id's first 8 hex chars (sans dashes) — m's pick
|
||||
-- 2026-05-26 (paliadin instruction).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- (a) codes-bearing rules → one procedural_events row per distinct code
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind, primary_party_default,
|
||||
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
||||
SELECT
|
||||
src.submission_code,
|
||||
src.name,
|
||||
src.name_en,
|
||||
src.description,
|
||||
src.event_type,
|
||||
src.primary_party,
|
||||
ls.id,
|
||||
src.concept_id,
|
||||
src.lifecycle_state,
|
||||
src.published_at,
|
||||
src.is_active
|
||||
FROM (
|
||||
SELECT DISTINCT ON (submission_code)
|
||||
submission_code, name, name_en, description, event_type,
|
||||
primary_party, concept_id, legal_source, lifecycle_state,
|
||||
published_at, is_active
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
ORDER BY submission_code, id, sequence_order
|
||||
) src
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.citation = src.legal_source;
|
||||
|
||||
-- (b) NULL-code rules → one synthetic procedural_events row each
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind, primary_party_default,
|
||||
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
||||
SELECT
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8) AS code,
|
||||
dr.name,
|
||||
dr.name_en,
|
||||
dr.description,
|
||||
dr.event_type,
|
||||
dr.primary_party,
|
||||
ls.id,
|
||||
dr.concept_id,
|
||||
dr.lifecycle_state,
|
||||
dr.published_at,
|
||||
dr.is_active
|
||||
FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.citation = dr.legal_source
|
||||
WHERE dr.submission_code IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 7. BACKFILL — sequencing_rules
|
||||
-- 1:1 with deadline_rules. id inherited so deadlines.rule_id FKs
|
||||
-- continue to resolve through the dual-write window (design §5.1
|
||||
-- step 4). procedural_event_id resolved by JOIN on the (real or
|
||||
-- synthetic) code.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
INSERT INTO paliad.sequencing_rules
|
||||
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
|
||||
duration_value, duration_unit, timing,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
combine_op, condition_expr, primary_party, sequence_order,
|
||||
is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
is_bilateral, is_court_set, priority,
|
||||
rule_code, rule_codes, deadline_notes, deadline_notes_en,
|
||||
choices_offered, applies_to_target,
|
||||
lifecycle_state, draft_of, published_at, is_active,
|
||||
created_at, updated_at)
|
||||
SELECT
|
||||
dr.id,
|
||||
pe.id,
|
||||
dr.proceeding_type_id,
|
||||
dr.parent_id,
|
||||
dr.trigger_event_id,
|
||||
dr.duration_value, dr.duration_unit, dr.timing,
|
||||
dr.alt_duration_value, dr.alt_duration_unit, dr.alt_rule_code, dr.anchor_alt,
|
||||
dr.combine_op, dr.condition_expr, dr.primary_party, dr.sequence_order,
|
||||
dr.is_spawn, dr.spawn_label, dr.spawn_proceeding_type_id,
|
||||
dr.is_bilateral, dr.is_court_set, dr.priority,
|
||||
dr.rule_code, dr.rule_codes, dr.deadline_notes, dr.deadline_notes_en,
|
||||
dr.choices_offered, dr.applies_to_target,
|
||||
dr.lifecycle_state,
|
||||
-- draft_of is a self-FK on deadline_rules; preserve as a self-FK on
|
||||
-- sequencing_rules since the inherited ids are stable across both.
|
||||
dr.draft_of,
|
||||
dr.published_at, dr.is_active,
|
||||
dr.created_at, dr.updated_at
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.procedural_events pe
|
||||
ON pe.code = COALESCE(
|
||||
dr.submission_code,
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 8. BACKFILL — paliad.deadlines link columns
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET procedural_event_id = sr.procedural_event_id,
|
||||
sequencing_rule_id = sr.id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE d.rule_id = sr.id;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 9. POST pass — integrity assertions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_dr_total int;
|
||||
v_dr_codes_distinct int;
|
||||
v_dr_codes_null int;
|
||||
v_dr_legal_distinct int;
|
||||
v_pe_total int;
|
||||
v_sr_total int;
|
||||
v_ls_total int;
|
||||
v_orphan_pe int;
|
||||
v_dup_synthetic int;
|
||||
v_deadlines_linked int;
|
||||
v_deadlines_total int;
|
||||
v_pe_missing_ls int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_dr_total FROM paliad.deadline_rules;
|
||||
SELECT COUNT(DISTINCT submission_code)
|
||||
INTO v_dr_codes_distinct FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_dr_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
|
||||
SELECT COUNT(DISTINCT legal_source)
|
||||
INTO v_dr_legal_distinct FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
|
||||
SELECT COUNT(*) INTO v_pe_total FROM paliad.procedural_events;
|
||||
SELECT COUNT(*) INTO v_sr_total FROM paliad.sequencing_rules;
|
||||
SELECT COUNT(*) INTO v_ls_total FROM paliad.legal_sources;
|
||||
SELECT COUNT(*) INTO v_deadlines_total FROM paliad.deadlines;
|
||||
SELECT COUNT(*) INTO v_deadlines_linked FROM paliad.deadlines WHERE procedural_event_id IS NOT NULL;
|
||||
|
||||
-- a. procedural_events row count = distinct_codes + null_codes
|
||||
IF v_pe_total <> v_dr_codes_distinct + v_dr_codes_null THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: procedural_events count mismatch — got %, expected % (% distinct codes + % null-code rules)',
|
||||
v_pe_total, v_dr_codes_distinct + v_dr_codes_null, v_dr_codes_distinct, v_dr_codes_null;
|
||||
END IF;
|
||||
|
||||
-- b. sequencing_rules row count = deadline_rules row count (1:1)
|
||||
IF v_sr_total <> v_dr_total THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: sequencing_rules count mismatch — got %, expected % (1:1 with deadline_rules)',
|
||||
v_sr_total, v_dr_total;
|
||||
END IF;
|
||||
|
||||
-- c. legal_sources row count = distinct legal_source in deadline_rules
|
||||
IF v_ls_total <> v_dr_legal_distinct THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: legal_sources count mismatch — got %, expected % (distinct legal_source)',
|
||||
v_ls_total, v_dr_legal_distinct;
|
||||
END IF;
|
||||
|
||||
-- d. every sequencing_rules row's procedural_event_id resolves
|
||||
SELECT COUNT(*)
|
||||
INTO v_orphan_pe
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.id IS NULL;
|
||||
IF v_orphan_pe > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % sequencing_rules row(s) have no resolving procedural_event_id', v_orphan_pe;
|
||||
END IF;
|
||||
|
||||
-- e. no two synthetic codes collide (would have crashed the INSERT
|
||||
-- via UNIQUE, but assert again for clarity — collision among 78
|
||||
-- UUIDs at 8 hex chars is ~6e-7 probability)
|
||||
SELECT COUNT(*)
|
||||
INTO v_dup_synthetic
|
||||
FROM (
|
||||
SELECT code, COUNT(*) AS n
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY code
|
||||
HAVING COUNT(*) > 1
|
||||
) d;
|
||||
IF v_dup_synthetic > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % synthetic codes collided. '
|
||||
'Re-run with a longer substring (16 hex chars instead of 8) '
|
||||
'or full uuid in the code-mint expression.', v_dup_synthetic;
|
||||
END IF;
|
||||
|
||||
-- f. every procedural_events.legal_source_id either resolves or is
|
||||
-- NULL (NULL is fine — 119 of 231 rules have NULL legal_source)
|
||||
SELECT COUNT(*)
|
||||
INTO v_pe_missing_ls
|
||||
FROM paliad.procedural_events pe
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
|
||||
WHERE pe.legal_source_id IS NOT NULL
|
||||
AND ls.id IS NULL;
|
||||
IF v_pe_missing_ls > 0 THEN
|
||||
RAISE EXCEPTION '[mig 136] FAILED POST: % procedural_events row(s) reference a missing legal_sources id', v_pe_missing_ls;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 136] POST: legal_sources=%, procedural_events=%, sequencing_rules=%, deadlines=% (% linked)',
|
||||
v_ls_total, v_pe_total, v_sr_total, v_deadlines_total, v_deadlines_linked;
|
||||
RAISE NOTICE '[mig 136] integrity OK — backfill complete. '
|
||||
'deadline_rules untouched (1:1 with sequencing_rules; '
|
||||
'ready for B.2 dual-write).';
|
||||
END $$;
|
||||
18
internal/db/migrations/137_proceeding_role_labels.down.sql
Normal file
18
internal/db/migrations/137_proceeding_role_labels.down.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 137_proceeding_role_labels — DOWN
|
||||
--
|
||||
-- Drops the 4 role-label columns. Backfilled data is lost on
|
||||
-- down-migration; that's acceptable because the frontend renderer
|
||||
-- falls back to the default labels ("Klägerseite" / "Beklagtenseite")
|
||||
-- when the columns are absent.
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_reactive_label_en;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_reactive_label_de;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_proactive_label_en;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS role_proactive_label_de;
|
||||
137
internal/db/migrations/137_proceeding_role_labels.up.sql
Normal file
137
internal/db/migrations/137_proceeding_role_labels.up.sql
Normal file
@@ -0,0 +1,137 @@
|
||||
-- 137_proceeding_role_labels — t-paliad-301, m/paliad#132
|
||||
--
|
||||
-- Bug A fix: per-proceeding role labels so the Verfahrensablauf side
|
||||
-- selector can render "Berufungskläger / Berufungsbeklagter" for the
|
||||
-- unified UPC Berufung tile instead of the generic "Klägerseite /
|
||||
-- Beklagtenseite".
|
||||
--
|
||||
-- Four new optional columns on paliad.proceeding_types. NULL on a
|
||||
-- column falls back to the language-default ("Klägerseite" / "Claimant
|
||||
-- side" / "Beklagtenseite" / "Defendant side") in the frontend renderer.
|
||||
-- Only the proceedings whose role-naming actually differs get a backfill.
|
||||
--
|
||||
-- Live-DB audit (mcp__supabase__execute_sql) before drafting:
|
||||
-- - paliad.proceeding_types has 14 columns; the 4 target columns do
|
||||
-- NOT exist (zero name collisions).
|
||||
-- - Zero triggers on paliad.proceeding_types. No audit_reason
|
||||
-- setup needed.
|
||||
-- - No updated_at / created_at on the table — DO NOT include
|
||||
-- timestamp UPDATEs (lesson from mig 134 HOTFIX 3).
|
||||
--
|
||||
-- ADDITIVE ONLY. ALTER + UPDATE statements; no CHECK constraints
|
||||
-- (the columns are free-text labels, validated at the application layer).
|
||||
-- Down migration drops the 4 columns.
|
||||
--
|
||||
-- See m/paliad#132 for the full design rationale + the role-label
|
||||
-- matrix per proceeding code.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Schema additions
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_proactive_label_de text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_proactive_label_en text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_reactive_label_de text NULL;
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN role_reactive_label_en text NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_de IS
|
||||
'DE label for the proactive (claimant-equivalent) side of this '
|
||||
'proceeding. NULL = renderer falls back to "Klägerseite". '
|
||||
't-paliad-301 / m/paliad#132 Bug A.';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_en IS
|
||||
'EN label for the proactive side. NULL = "Claimant side".';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_de IS
|
||||
'DE label for the reactive (defendant-equivalent) side. NULL = '
|
||||
'"Beklagtenseite".';
|
||||
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_en IS
|
||||
'EN label for the reactive side. NULL = "Defendant side".';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Audit-first NOTICE pass.
|
||||
--
|
||||
-- Lists which proceeding_types are about to receive a backfill so
|
||||
-- the operator sees the scope before the UPDATE fires. NULL columns
|
||||
-- on every other row stay NULL (the frontend falls back to defaults).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
backfill_count int := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 137] Proceedings that will receive role-label backfill:';
|
||||
FOR rec IN
|
||||
SELECT code, name
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code IN ('upc.apl.unified', 'upc.rev.cfi', 'epa.opp.opd', 'epa.opp.boa')
|
||||
ORDER BY code
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 137] % %', rec.code, rec.name;
|
||||
backfill_count := backfill_count + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE '[mig 137] Total: % proceedings (others stay NULL → renderer default)', backfill_count;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Backfill.
|
||||
--
|
||||
-- Per the design matrix in m/paliad#132:
|
||||
-- - upc.apl.unified → Berufungskläger / Berufungsbeklagter / Appellant / Appellee
|
||||
-- - upc.rev.cfi → Antragsteller (Nichtigkeit) / Antragsgegner (Nichtigkeit) /
|
||||
-- Revocation claimant / Revocation defendant
|
||||
-- - epa.opp.opd → Einsprechende(r) / Patentinhaber(in) /
|
||||
-- Opponent / Patentee
|
||||
-- - epa.opp.boa → Einsprechende(r) / Patentinhaber(in) /
|
||||
-- Opponent / Patentee
|
||||
-- - (others) → stay NULL → frontend defaults
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Berufungskläger',
|
||||
role_reactive_label_de = 'Berufungsbeklagter',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Appellee'
|
||||
WHERE code = 'upc.apl.unified';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Antragsteller (Nichtigkeit)',
|
||||
role_reactive_label_de = 'Antragsgegner (Nichtigkeit)',
|
||||
role_proactive_label_en = 'Revocation claimant',
|
||||
role_reactive_label_en = 'Revocation defendant'
|
||||
WHERE code = 'upc.rev.cfi';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Einsprechende(r)',
|
||||
role_reactive_label_de = 'Patentinhaber(in)',
|
||||
role_proactive_label_en = 'Opponent',
|
||||
role_reactive_label_en = 'Patentee'
|
||||
WHERE code IN ('epa.opp.opd', 'epa.opp.boa');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Post-migration NOTICE — informational only.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 137] post: backfilled role-label distribution:';
|
||||
FOR rec IN
|
||||
SELECT code,
|
||||
role_proactive_label_de,
|
||||
role_reactive_label_de
|
||||
FROM paliad.proceeding_types
|
||||
WHERE role_proactive_label_de IS NOT NULL
|
||||
ORDER BY code
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 137] % proactive=% reactive=%',
|
||||
rec.code, rec.role_proactive_label_de, rec.role_reactive_label_de;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -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 $$;
|
||||
@@ -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 $$;
|
||||
@@ -69,6 +69,14 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// stay in the result list. Default false preserves the legacy
|
||||
// suppression. HiddenCount on the response is independent.
|
||||
IncludeHidden bool `json:"includeHidden,omitempty"`
|
||||
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC
|
||||
// Berufung (upc.apl) timeline to the rule subset whose
|
||||
// applies_to_target contains the requested slug. Empty = no
|
||||
// filter. Valid values: endentscheidung | kostenentscheidung
|
||||
// | anordnung | schadensbemessung | bucheinsicht. Unknown
|
||||
// slugs are silently dropped (no filter) so a stale frontend
|
||||
// chip doesn't 400 the request.
|
||||
AppealTarget string `json:"appealTarget,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -116,6 +124,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -35,11 +35,14 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||
choices_offered`
|
||||
choices_offered, applies_to_target`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en`
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target,
|
||||
role_proactive_label_de, role_proactive_label_en,
|
||||
role_reactive_label_de, role_reactive_label_en`
|
||||
|
||||
// List returns active rules, optionally filtered by proceeding type.
|
||||
// Each row has ConceptDefaultEventTypeID hydrated from
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
|
||||
// FristenrechnerService renders the Paliad public Fristenrechner's
|
||||
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
|
||||
// is a thin adapter: the compute engine + types live in
|
||||
@@ -223,6 +226,296 @@ func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64)
|
||||
return c.rules.LoadTriggerEventsByIDs(ctx, ids)
|
||||
}
|
||||
|
||||
// LookupEvents queries paliad.deadline_rules for rules matching the
|
||||
// requested axes, then walks the parent_id graph in Go to honour the
|
||||
// requested depth. Slice B2 (m/paliad#124 §18.2).
|
||||
//
|
||||
// Filter axes apply at the SQL layer:
|
||||
// - Jurisdiction: WHERE paliad.proceeding_types.jurisdiction = $X
|
||||
// - ProceedingTypeID: WHERE deadline_rules.proceeding_type_id = $X
|
||||
// - Party: WHERE deadline_rules.primary_party = $X
|
||||
// - EventCategoryID: EXISTS subquery on
|
||||
// paliad.event_category_concepts joined via concept_id
|
||||
// - AppealTarget: WHERE $X = ANY(deadline_rules.applies_to_target)
|
||||
//
|
||||
// Depth is applied post-fetch: for EventLookupDepthNext, anchor rules
|
||||
// (matched directly) are returned at depth=1 + their immediate
|
||||
// children (parent_id IN matched-set) at depth=2. For
|
||||
// EventLookupDepthAllFollowing, the parent_id walk continues
|
||||
// recursively. The walk stays within the per-proceeding rule set
|
||||
// (cross-proceeding spawn following is handled by the engine, not by
|
||||
// LookupEvents).
|
||||
//
|
||||
// "published + active" gate: lifecycle_state='published' AND
|
||||
// is_active=true (matches LoadProceeding's WHERE clause).
|
||||
func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
|
||||
// Validate axis values up front; unknown values fall through as
|
||||
// "no filter on this axis" so a stale frontend chip doesn't
|
||||
// silently drop the entire result set.
|
||||
jurisdiction := axes.Jurisdiction
|
||||
if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" &&
|
||||
jurisdiction != "EPA" && jurisdiction != "DPMA" {
|
||||
jurisdiction = ""
|
||||
}
|
||||
party := axes.Party
|
||||
if party != "" && !lp.IsValidPrimaryParty(party) {
|
||||
party = ""
|
||||
}
|
||||
appealTarget := axes.AppealTarget
|
||||
if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) {
|
||||
appealTarget = ""
|
||||
}
|
||||
|
||||
// Build the WHERE clause progressively. Each axis adds a $N
|
||||
// placeholder + appends to the args slice.
|
||||
where := []string{
|
||||
"dr.is_active = true",
|
||||
"dr.lifecycle_state = 'published'",
|
||||
"pt.is_active = true",
|
||||
}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
where = append(where, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if jurisdiction != "" {
|
||||
add("pt.jurisdiction = $%d", jurisdiction)
|
||||
}
|
||||
if axes.ProceedingTypeID != nil {
|
||||
add("dr.proceeding_type_id = $%d", *axes.ProceedingTypeID)
|
||||
}
|
||||
if party != "" {
|
||||
add("dr.primary_party = $%d", party)
|
||||
}
|
||||
if axes.EventCategoryID != nil {
|
||||
// Junction-table EXISTS: the rule's concept_id must appear in
|
||||
// paliad.event_category_concepts with the matching
|
||||
// event_category_id.
|
||||
add(`EXISTS (
|
||||
SELECT 1 FROM paliad.event_category_concepts ecc
|
||||
WHERE ecc.event_category_id = $%d
|
||||
AND ecc.concept_id = dr.concept_id
|
||||
)`, *axes.EventCategoryID)
|
||||
}
|
||||
if appealTarget != "" {
|
||||
add("$%d = ANY(dr.applies_to_target)", appealTarget)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT ` + ruleColumns + `,
|
||||
pt.id AS pt_id, pt.code AS pt_code, pt.name AS pt_name,
|
||||
pt.name_en AS pt_name_en, pt.description AS pt_description,
|
||||
pt.jurisdiction AS pt_jurisdiction, pt.category AS pt_category,
|
||||
pt.default_color AS pt_default_color, pt.sort_order AS pt_sort_order,
|
||||
pt.is_active AS pt_is_active,
|
||||
pt.trigger_event_label_de AS pt_trigger_event_label_de,
|
||||
pt.trigger_event_label_en AS pt_trigger_event_label_en,
|
||||
pt.appeal_target AS pt_appeal_target
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE ` + strings.Join(where, "\n AND ") + `
|
||||
ORDER BY dr.proceeding_type_id, dr.sequence_order`
|
||||
|
||||
var rows []lookupEventsRow
|
||||
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("lookup events: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return []lp.EventMatch{}, nil
|
||||
}
|
||||
|
||||
// matchedIDs is the set of rule IDs that satisfied the axes (the
|
||||
// "anchor" matches at depth=1). For EventLookupDepthNext we add
|
||||
// their direct children. For EventLookupDepthAllFollowing we walk
|
||||
// the parent_id chain transitively.
|
||||
matchedIDs := make(map[uuid.UUID]bool, len(rows))
|
||||
anchorMatch := make(map[uuid.UUID]bool, len(rows))
|
||||
rowByID := make(map[uuid.UUID]lookupEventsRow, len(rows))
|
||||
for _, r := range rows {
|
||||
matchedIDs[r.ID] = true
|
||||
anchorMatch[r.ID] = true
|
||||
rowByID[r.ID] = r
|
||||
}
|
||||
|
||||
// For depth control we need the full per-proceeding rule corpus
|
||||
// (so we can find children whose parent_id ∈ matchedIDs even when
|
||||
// those children don't match the axes themselves). Skip this when
|
||||
// depth is empty (treated as "anchors only" — undocumented but
|
||||
// useful as a degenerate case).
|
||||
expandFromCorpus := func(corpus []models.DeadlineRule, joinedFor map[int]lookupEventsRow) {
|
||||
// We loop until no new descendants are added (transitive
|
||||
// closure under parent_id ∈ matchedIDs). EventLookupDepthNext
|
||||
// stops after one pass; AllFollowing iterates to fixpoint.
|
||||
for {
|
||||
grew := false
|
||||
for _, r := range corpus {
|
||||
if r.ParentID == nil {
|
||||
continue
|
||||
}
|
||||
if !matchedIDs[*r.ParentID] {
|
||||
continue
|
||||
}
|
||||
if matchedIDs[r.ID] {
|
||||
continue
|
||||
}
|
||||
matchedIDs[r.ID] = true
|
||||
j := joinedFor[*r.ProceedingTypeID]
|
||||
rowByID[r.ID] = lookupEventsRow{
|
||||
DeadlineRule: r,
|
||||
PTID: j.PTID,
|
||||
PTCode: j.PTCode,
|
||||
PTName: j.PTName,
|
||||
PTNameEN: j.PTNameEN,
|
||||
PTDescription: j.PTDescription,
|
||||
PTJurisdiction: j.PTJurisdiction,
|
||||
PTCategory: j.PTCategory,
|
||||
PTDefaultColor: j.PTDefaultColor,
|
||||
PTSortOrder: j.PTSortOrder,
|
||||
PTIsActive: j.PTIsActive,
|
||||
PTTriggerEventLabelDE: j.PTTriggerEventLabelDE,
|
||||
PTTriggerEventLabelEN: j.PTTriggerEventLabelEN,
|
||||
PTAppealTarget: j.PTAppealTarget,
|
||||
}
|
||||
grew = true
|
||||
}
|
||||
if !grew || depth == lp.EventLookupDepthNext {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
|
||||
// Load the proceeding-scoped corpus for every proceeding_type
|
||||
// that appeared in the anchor set. The walk needs full
|
||||
// visibility into each proceeding's rule tree so it can
|
||||
// resolve parent_id chains.
|
||||
procIDs := make(map[int]struct{})
|
||||
joinedFor := make(map[int]lookupEventsRow)
|
||||
for _, r := range rows {
|
||||
procIDs[r.PTID] = struct{}{}
|
||||
if _, ok := joinedFor[r.PTID]; !ok {
|
||||
joinedFor[r.PTID] = r
|
||||
}
|
||||
}
|
||||
for ptID := range procIDs {
|
||||
corpus, err := c.rules.List(ctx, &ptID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup events: load proceeding %d corpus: %w", ptID, err)
|
||||
}
|
||||
expandFromCorpus(corpus, joinedFor)
|
||||
}
|
||||
}
|
||||
|
||||
// depths[id] = sequence-depth from the closest anchor ancestor.
|
||||
// Anchors are depth=1; their direct children are depth=2; etc.
|
||||
depths := computeDepths(rowByID, anchorMatch)
|
||||
|
||||
// Compose the result slice ordered by (PTID, sequence_order).
|
||||
type withKey struct {
|
||||
match lp.EventMatch
|
||||
key int64
|
||||
}
|
||||
items := make([]withKey, 0, len(matchedIDs))
|
||||
for id := range matchedIDs {
|
||||
r := rowByID[id]
|
||||
var parentRuleID *uuid.UUID
|
||||
if r.ParentID != nil && matchedIDs[*r.ParentID] {
|
||||
p := *r.ParentID
|
||||
parentRuleID = &p
|
||||
}
|
||||
items = append(items, withKey{
|
||||
match: lp.EventMatch{
|
||||
Rule: r.DeadlineRule,
|
||||
ProceedingType: lp.ProceedingType{
|
||||
ID: r.PTID,
|
||||
Code: r.PTCode,
|
||||
Name: r.PTName,
|
||||
NameEN: r.PTNameEN,
|
||||
Description: r.PTDescription,
|
||||
Jurisdiction: r.PTJurisdiction,
|
||||
Category: r.PTCategory,
|
||||
DefaultColor: r.PTDefaultColor,
|
||||
SortOrder: r.PTSortOrder,
|
||||
IsActive: r.PTIsActive,
|
||||
TriggerEventLabelDE: r.PTTriggerEventLabelDE,
|
||||
TriggerEventLabelEN: r.PTTriggerEventLabelEN,
|
||||
AppealTarget: r.PTAppealTarget,
|
||||
},
|
||||
Priority: r.Priority,
|
||||
DepthFromAnchor: depths[id],
|
||||
ParentRuleID: parentRuleID,
|
||||
},
|
||||
key: int64(r.PTID)*1_000_000 + int64(r.SequenceOrder),
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(a, b int) bool { return items[a].key < items[b].key })
|
||||
out := make([]lp.EventMatch, len(items))
|
||||
for i, it := range items {
|
||||
out[i] = it.match
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lookupEventsRow is the joined SELECT shape for LookupEvents — one
|
||||
// deadline_rules row plus its proceeding_types parent columns. Kept
|
||||
// at package scope so computeDepths can reference it.
|
||||
type lookupEventsRow struct {
|
||||
models.DeadlineRule
|
||||
PTID int `db:"pt_id"`
|
||||
PTCode string `db:"pt_code"`
|
||||
PTName string `db:"pt_name"`
|
||||
PTNameEN string `db:"pt_name_en"`
|
||||
PTDescription *string `db:"pt_description"`
|
||||
PTJurisdiction *string `db:"pt_jurisdiction"`
|
||||
PTCategory *string `db:"pt_category"`
|
||||
PTDefaultColor string `db:"pt_default_color"`
|
||||
PTSortOrder int `db:"pt_sort_order"`
|
||||
PTIsActive bool `db:"pt_is_active"`
|
||||
PTTriggerEventLabelDE *string `db:"pt_trigger_event_label_de"`
|
||||
PTTriggerEventLabelEN *string `db:"pt_trigger_event_label_en"`
|
||||
PTAppealTarget *string `db:"pt_appeal_target"`
|
||||
}
|
||||
|
||||
// computeDepths walks from each rule up the parent_id chain until it
|
||||
// hits an anchor match (or runs out). The depth of the anchor is 1;
|
||||
// each step away adds one. Rules whose entire chain has no anchor
|
||||
// (defensive — shouldn't happen given the expand-from-corpus walk
|
||||
// only adds children of matched parents) get depth=1.
|
||||
//
|
||||
// Iteration-bounded by the corpus size to prevent infinite loops on
|
||||
// hypothetical parent_id cycles (mig 134 + the schema CHECKs already
|
||||
// preclude cycles, but the bound is cheap insurance).
|
||||
func computeDepths(
|
||||
rowByID map[uuid.UUID]lookupEventsRow,
|
||||
anchors map[uuid.UUID]bool,
|
||||
) map[uuid.UUID]int {
|
||||
depths := make(map[uuid.UUID]int, len(rowByID))
|
||||
for id := range rowByID {
|
||||
if anchors[id] {
|
||||
depths[id] = 1
|
||||
continue
|
||||
}
|
||||
// Walk parents until we find an anchor or run out.
|
||||
d := 1
|
||||
cur := id
|
||||
maxIter := len(rowByID) + 1
|
||||
for i := 0; i < maxIter; i++ {
|
||||
r := rowByID[cur]
|
||||
if r.ParentID == nil {
|
||||
break
|
||||
}
|
||||
d++
|
||||
cur = *r.ParentID
|
||||
if anchors[cur] {
|
||||
break
|
||||
}
|
||||
}
|
||||
depths[id] = d
|
||||
}
|
||||
return depths
|
||||
}
|
||||
|
||||
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
|
||||
var _ lp.Catalog = (*paliadCatalog)(nil)
|
||||
|
||||
|
||||
219
internal/services/lookup_events_test.go
Normal file
219
internal/services/lookup_events_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// TestLookupEvents covers the multi-axis catalog query API from Slice
|
||||
// B2 (m/paliad#124 §18.2). Skipped when TEST_DATABASE_URL is unset,
|
||||
// mirroring TestCalculateRule.
|
||||
//
|
||||
// Cases:
|
||||
// - jurisdiction=UPC, depth=all-following → every active+published
|
||||
// UPC rule, anchor depth=1 for all (no parent_id outside the
|
||||
// filtered set lights up depth>1 because the entire UPC subset is
|
||||
// a single anchor cohort).
|
||||
// - proceeding_type_id (upc.inf.cfi) + party=defendant + depth=next
|
||||
// → defendant rules in upc.inf.cfi at depth=1 + direct children
|
||||
// of those at depth=2.
|
||||
// - unknown jurisdiction value → silently ignored, no filter applied.
|
||||
// - empty axes → all rules (no filter on any axis).
|
||||
func TestLookupEvents(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()
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
catalog := &paliadCatalog{rules: rules}
|
||||
|
||||
t.Run("jurisdiction=UPC, all-following returns the UPC corpus", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected non-empty UPC corpus")
|
||||
}
|
||||
// Every match must be a UPC rule.
|
||||
for _, m := range matches {
|
||||
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
|
||||
t.Errorf("non-UPC row leaked into UPC-axis query: code=%s jurisdiction=%v",
|
||||
m.ProceedingType.Code, m.ProceedingType.Jurisdiction)
|
||||
}
|
||||
if m.DepthFromAnchor < 1 {
|
||||
t.Errorf("depth=%d for rule %s, want >= 1", m.DepthFromAnchor, m.Rule.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("party=defendant scopes to defendant rules", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
Party: "defendant",
|
||||
}, lp.EventLookupDepthNext)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected at least one defendant rule across the UPC corpus")
|
||||
}
|
||||
// Anchor matches (depth=1) must be primary_party=defendant.
|
||||
// Depth=2 children appear under EventLookupDepthNext only as
|
||||
// expansion from anchors — they may carry any party.
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
|
||||
t.Errorf("anchor row %s (depth=1) is not defendant: %v",
|
||||
m.Rule.Name, m.Rule.PrimaryParty)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown jurisdiction value silently falls through", func(t *testing.T) {
|
||||
matchesAll, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{},
|
||||
lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents (all): %v", err)
|
||||
}
|
||||
matchesUnknown, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "XX-not-a-real-jurisdiction",
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents (unknown): %v", err)
|
||||
}
|
||||
if len(matchesAll) != len(matchesUnknown) {
|
||||
t.Errorf("unknown jurisdiction should fall through to no-filter; got %d vs all-axes %d",
|
||||
len(matchesUnknown), len(matchesAll))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=endentscheidung returns upc.apl merits rules", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetEndentscheidung,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// Should hit the 7 rules under the unified upc.apl that
|
||||
// carry applies_to_target={endentscheidung} (Slice B1 mig 134).
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl endentscheidung rules after B1 mig")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue // children of anchors may be from other targets
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetEndentscheidung {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("anchor row %s missing endentscheidung 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=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,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
55
pkg/litigationplanner/appeal_target_label_test.go
Normal file
55
pkg/litigationplanner/appeal_target_label_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package litigationplanner
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestTriggerEventLabelForAppealTarget pins the per-target trigger-
|
||||
// event label matrix (t-paliad-301 / m/paliad#132 Bug B). The 5
|
||||
// canonical AppealTargets each have a DE + EN label; unknown targets
|
||||
// return empty so the caller can fall back to the proceeding's own
|
||||
// trigger_event_label.
|
||||
func TestTriggerEventLabelForAppealTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
target string
|
||||
lang string
|
||||
want string
|
||||
}{
|
||||
{AppealTargetEndentscheidung, "de", "Endentscheidung (R.118)"},
|
||||
{AppealTargetEndentscheidung, "en", "Final decision (R.118)"},
|
||||
{AppealTargetKostenentscheidung, "de", "Kostenentscheidung"},
|
||||
{AppealTargetKostenentscheidung, "en", "Cost decision"},
|
||||
{AppealTargetAnordnung, "de", "Anordnung"},
|
||||
{AppealTargetAnordnung, "en", "Order"},
|
||||
{AppealTargetSchadensbemessung, "de", "Entscheidung im Schadensbemessungsverfahren"},
|
||||
{AppealTargetSchadensbemessung, "en", "Damages-assessment decision"},
|
||||
{AppealTargetBucheinsicht, "de", "Anordnung der Bucheinsicht"},
|
||||
{AppealTargetBucheinsicht, "en", "Book-inspection order"},
|
||||
// Unknown lang falls through to DE so the caller never gets
|
||||
// an empty string for a known target.
|
||||
{AppealTargetEndentscheidung, "fr", "Endentscheidung (R.118)"},
|
||||
// Unknown target → empty so caller falls back to proceeding's
|
||||
// trigger_event_label.
|
||||
{"", "de", ""},
|
||||
{"foo", "en", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := TriggerEventLabelForAppealTarget(c.target, c.lang); got != c.want {
|
||||
t.Errorf("TriggerEventLabelForAppealTarget(%q, %q) = %q, want %q",
|
||||
c.target, c.lang, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppealTargetsCoverage ensures every entry in AppealTargets has
|
||||
// a non-empty label in both languages. Adding a target to the slice
|
||||
// without populating the switch would silently emit empty labels —
|
||||
// this test catches that.
|
||||
func TestAppealTargetsCoverage(t *testing.T) {
|
||||
for _, target := range AppealTargets {
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
if got := TriggerEventLabelForAppealTarget(target, lang); got == "" {
|
||||
t.Errorf("AppealTarget %q has empty label for lang %q — add it to the switch",
|
||||
target, lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,17 @@ type Catalog interface {
|
||||
// are simply absent (caller treats absence as "no override").
|
||||
// Empty input returns an empty map without a DB roundtrip.
|
||||
LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error)
|
||||
|
||||
// LookupEvents returns deadline rules matching any subset of the
|
||||
// requested axes, at the requested sequence depth (Slice B2,
|
||||
// m/paliad#124 §18.2). Used by the Determinator cascade, the
|
||||
// scenarios surface (Slice D), and any future "show me events
|
||||
// matching X" query. Empty result is NOT an error.
|
||||
//
|
||||
// Implementations must respect the catalog's "published + active"
|
||||
// rule gate (rules with lifecycle_state='draft' or is_active=false
|
||||
// must NEVER appear in the result). Sort order is
|
||||
// (proceeding_type_id, sequence_order) so the frontend can render
|
||||
// without re-sorting.
|
||||
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
|
||||
}
|
||||
|
||||
66
pkg/litigationplanner/embedded/upc/courts.go
Normal file
66
pkg/litigationplanner/embedded/upc/courts.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package upc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// SnapshotCourt is the embedded court row shape. Mirrors paliad.courts.
|
||||
type SnapshotCourt struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
NameDE string `json:"name_de"`
|
||||
NameEN string `json:"name_en"`
|
||||
Country string `json:"country"`
|
||||
Regime *string `json:"regime,omitempty"`
|
||||
CourtType string `json:"court_type"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// SnapshotCourtRegistry serves CourtRegistry against the embedded
|
||||
// court slice. UPC subset only (DE / EPA / DPMA courts are NOT in
|
||||
// the snapshot — youpc.org has no need for them, and a request for
|
||||
// a non-UPC court id falls through to default country/regime per the
|
||||
// CountryRegime contract).
|
||||
type SnapshotCourtRegistry struct {
|
||||
byID map[string]SnapshotCourt
|
||||
}
|
||||
|
||||
// NewCourtRegistry parses the embedded courts.json and returns a
|
||||
// ready-to-use registry.
|
||||
func NewCourtRegistry() (*SnapshotCourtRegistry, error) {
|
||||
var courts []SnapshotCourt
|
||||
if err := readJSON("courts.json", &courts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &SnapshotCourtRegistry{byID: make(map[string]SnapshotCourt, len(courts))}
|
||||
for _, c := range courts {
|
||||
r.byID[c.ID] = c
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CountryRegime resolves a court ID to its (country, regime) tuple.
|
||||
// Empty courtID falls back to (defaultCountry, defaultRegime) per the
|
||||
// interface contract. ErrUnknownCourt-equivalent (a plain error here)
|
||||
// when courtID is non-empty but absent from the snapshot.
|
||||
func (r *SnapshotCourtRegistry) CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error) {
|
||||
if courtID == "" {
|
||||
return defaultCountry, defaultRegime, nil
|
||||
}
|
||||
c, ok := r.byID[courtID]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("upc snapshot: unknown court id %q", courtID)
|
||||
}
|
||||
reg := ""
|
||||
if c.Regime != nil {
|
||||
reg = *c.Regime
|
||||
}
|
||||
return c.Country, reg, nil
|
||||
}
|
||||
|
||||
// Compile-time assertion that SnapshotCourtRegistry satisfies
|
||||
// lp.CourtRegistry.
|
||||
var _ lp.CourtRegistry = (*SnapshotCourtRegistry)(nil)
|
||||
22
pkg/litigationplanner/embedded/upc/courts.json
Normal file
22
pkg/litigationplanner/embedded/upc/courts.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"id": "upc-ld-munich",
|
||||
"code": "upc-ld-munich",
|
||||
"name_de": "UPC Lokalkammer München",
|
||||
"name_en": "UPC Local Division Munich",
|
||||
"country": "DE",
|
||||
"regime": "UPC",
|
||||
"court_type": "upc-ld",
|
||||
"sort_order": 10
|
||||
},
|
||||
{
|
||||
"id": "upc-coa",
|
||||
"code": "upc-coa",
|
||||
"name_de": "UPC Berufungsgericht",
|
||||
"name_en": "UPC Court of Appeal",
|
||||
"country": "LU",
|
||||
"regime": "UPC",
|
||||
"court_type": "upc-coa",
|
||||
"sort_order": 100
|
||||
}
|
||||
]
|
||||
80
pkg/litigationplanner/embedded/upc/embed.go
Normal file
80
pkg/litigationplanner/embedded/upc/embed.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Package upc provides an embedded, DB-free implementation of the
|
||||
// litigationplanner Catalog / HolidayCalendar / CourtRegistry
|
||||
// interfaces, populated from a JSON snapshot of paliad's UPC rule
|
||||
// corpus.
|
||||
//
|
||||
// Slice C of the litigation-planner extraction (m/paliad#124 §19).
|
||||
//
|
||||
// Consumers (today: youpc.org; future: any third-party UPC tool) wire
|
||||
// the engine like this:
|
||||
//
|
||||
// import (
|
||||
// lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
// upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"
|
||||
// )
|
||||
//
|
||||
// cat, _ := upc.NewCatalog()
|
||||
// hc, _ := upc.NewHolidayCalendar()
|
||||
// cr, _ := upc.NewCourtRegistry()
|
||||
//
|
||||
// timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
|
||||
// lp.CalcOptions{}, cat, hc, cr)
|
||||
//
|
||||
// Regenerating the snapshot: see cmd/gen-upc-snapshot/README.md.
|
||||
//
|
||||
//go:generate sh -c "echo 'snapshot is regenerated via the gen-upc-snapshot binary — see cmd/gen-upc-snapshot/README.md'"
|
||||
package upc
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rawFS holds the snapshot JSON files. The data files are produced by
|
||||
// cmd/gen-upc-snapshot from a paliad live DB.
|
||||
//
|
||||
//go:embed *.json
|
||||
var rawFS embed.FS
|
||||
|
||||
// Meta is the version block from meta.json.
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
PaliadCommit string `json:"paliad_commit,omitempty"`
|
||||
SourceDBLabel string `json:"source_db_label,omitempty"`
|
||||
RuleCount int `json:"rule_count"`
|
||||
ProceedingCount int `json:"proceeding_count"`
|
||||
TriggerEventCount int `json:"trigger_event_count"`
|
||||
HolidayCount int `json:"holiday_count"`
|
||||
CourtCount int `json:"court_count"`
|
||||
}
|
||||
|
||||
// LoadMeta parses meta.json from the embedded snapshot. Returns an
|
||||
// error when the snapshot hasn't been generated yet (meta.json
|
||||
// missing or empty).
|
||||
func LoadMeta() (Meta, error) {
|
||||
var m Meta
|
||||
buf, err := rawFS.ReadFile("meta.json")
|
||||
if err != nil {
|
||||
return Meta{}, fmt.Errorf("read meta.json: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(buf, &m); err != nil {
|
||||
return Meta{}, fmt.Errorf("decode meta.json: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// readJSON is a tiny helper that decodes one of the embedded files
|
||||
// into a destination value.
|
||||
func readJSON(name string, dst any) error {
|
||||
buf, err := rawFS.ReadFile(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
if err := json.Unmarshal(buf, dst); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
216
pkg/litigationplanner/embedded/upc/holidays.go
Normal file
216
pkg/litigationplanner/embedded/upc/holidays.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package upc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// SnapshotHoliday is the embedded holiday row shape. Mirrors
|
||||
// paliad.holidays + the generator's output. Country and Regime are
|
||||
// optional pointers — at least one of them is non-empty on every
|
||||
// row (matches paliad's CHECK).
|
||||
type SnapshotHoliday struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
Name string `json:"name"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
Regime *string `json:"regime,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
HolidayType string `json:"holiday_type"`
|
||||
}
|
||||
|
||||
func (h SnapshotHoliday) appliesTo(country, regime string) bool {
|
||||
if h.Country != nil && country != "" && *h.Country == country {
|
||||
return true
|
||||
}
|
||||
if h.Regime != nil && regime != "" && *h.Regime == regime {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h SnapshotHoliday) isVacation() bool { return h.HolidayType == "vacation" }
|
||||
func (h SnapshotHoliday) isClosure() bool { return h.HolidayType == "closure" }
|
||||
|
||||
// SnapshotHolidayCalendar serves HolidayCalendar against the embedded
|
||||
// holiday slice. The semantics mirror paliad's HolidayService:
|
||||
//
|
||||
// - IsNonWorkingDay = weekend OR a closure/vacation row matching
|
||||
// the (country, regime) pair
|
||||
// - AdjustForNonWorkingDays = walk forward day-by-day until
|
||||
// IsNonWorkingDay returns false (bounded at 60 iters)
|
||||
// - AdjustForNonWorkingDaysBackward = same but stepping -1 day
|
||||
// - AdjustForNonWorkingDaysWithReason = forward walk + structured
|
||||
// reason payload (vacation > public_holiday > weekend)
|
||||
type SnapshotHolidayCalendar struct {
|
||||
byDate map[string][]SnapshotHoliday // keyed by YYYY-MM-DD
|
||||
}
|
||||
|
||||
// NewHolidayCalendar parses the embedded holidays.json and returns a
|
||||
// ready-to-use calendar.
|
||||
func NewHolidayCalendar() (*SnapshotHolidayCalendar, error) {
|
||||
var holidays []SnapshotHoliday
|
||||
if err := readJSON("holidays.json", &holidays); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cal := &SnapshotHolidayCalendar{byDate: make(map[string][]SnapshotHoliday, len(holidays))}
|
||||
for _, h := range holidays {
|
||||
cal.byDate[h.Date] = append(cal.byDate[h.Date], h)
|
||||
}
|
||||
return cal, nil
|
||||
}
|
||||
|
||||
// IsNonWorkingDay returns true on weekends or closure/vacation
|
||||
// holidays applicable to the given country/regime.
|
||||
func (c *SnapshotHolidayCalendar) IsNonWorkingDay(date time.Time, country, regime string) bool {
|
||||
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||||
return true
|
||||
}
|
||||
key := date.Format("2006-01-02")
|
||||
for _, h := range c.byDate[key] {
|
||||
if !h.appliesTo(country, regime) {
|
||||
continue
|
||||
}
|
||||
if h.isClosure() || h.isVacation() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *SnapshotHolidayCalendar) holidayMatch(date time.Time, country, regime string) *SnapshotHoliday {
|
||||
key := date.Format("2006-01-02")
|
||||
for _, h := range c.byDate[key] {
|
||||
if !h.appliesTo(country, regime) {
|
||||
continue
|
||||
}
|
||||
hh := h
|
||||
return &hh
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDays walks forward until the date lands on a
|
||||
// working day. Bound = 60 iters (same as paliad — generous safety
|
||||
// margin past any vacation run).
|
||||
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, 1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDaysBackward walks backward until the date lands
|
||||
// on a working day. Same bound.
|
||||
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, -1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDaysWithReason is the structured-explanation
|
||||
// counterpart to AdjustForNonWorkingDays. Reason kind precedence
|
||||
// (longest cause wins): vacation > public_holiday > weekend. Reason
|
||||
// is nil when wasAdjusted is false.
|
||||
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *lp.AdjustmentReason) {
|
||||
original = date
|
||||
adjusted = date
|
||||
|
||||
var holidaysHit []lp.HolidayDTO
|
||||
seen := map[string]bool{}
|
||||
var sawWeekend, sawVacation, sawPublicHoliday bool
|
||||
var vacationName string
|
||||
|
||||
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||||
if wd := adjusted.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||||
sawWeekend = true
|
||||
}
|
||||
if h := c.holidayMatch(adjusted, country, regime); h != nil {
|
||||
if h.isVacation() {
|
||||
sawVacation = true
|
||||
if vacationName == "" {
|
||||
vacationName = h.Name
|
||||
}
|
||||
} else if h.isClosure() {
|
||||
sawPublicHoliday = true
|
||||
}
|
||||
key := h.Date + "|" + h.Name
|
||||
if !seen[key] {
|
||||
holidaysHit = append(holidaysHit, lp.HolidayDTO{
|
||||
Date: h.Date,
|
||||
Name: h.Name,
|
||||
IsVacation: h.isVacation(),
|
||||
IsClosure: h.isClosure(),
|
||||
})
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
adjusted = adjusted.AddDate(0, 0, 1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
if !wasAdjusted {
|
||||
return adjusted, original, false, nil
|
||||
}
|
||||
r := &lp.AdjustmentReason{Holidays: holidaysHit}
|
||||
switch {
|
||||
case sawVacation:
|
||||
r.Kind = "vacation"
|
||||
r.VacationName = vacationName
|
||||
if vs, ve, ok := c.findVacationBlock(original, country, regime); ok {
|
||||
r.VacationStart = vs.Format("2006-01-02")
|
||||
r.VacationEnd = ve.Format("2006-01-02")
|
||||
}
|
||||
case sawPublicHoliday:
|
||||
r.Kind = "public_holiday"
|
||||
default:
|
||||
r.Kind = "weekend"
|
||||
}
|
||||
if sawWeekend && r.Kind == "weekend" {
|
||||
r.OriginalWeekday = original.Weekday().String()
|
||||
}
|
||||
return adjusted, original, true, r
|
||||
}
|
||||
|
||||
// findVacationBlock scans outward from date through non-working days
|
||||
// to locate the first/last IsVacation entries. Weekends inside the
|
||||
// run are traversed but don't extend the reported span — start/end
|
||||
// are always real vacation entries.
|
||||
func (c *SnapshotHolidayCalendar) findVacationBlock(date time.Time, country, regime string) (start, end time.Time, ok bool) {
|
||||
cur := date
|
||||
for i := 0; i < 60; i++ {
|
||||
if !c.IsNonWorkingDay(cur, country, regime) {
|
||||
break
|
||||
}
|
||||
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
|
||||
start = cur
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
cur = cur.AddDate(0, 0, -1)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cur = date
|
||||
for i := 0; i < 60; i++ {
|
||||
if !c.IsNonWorkingDay(cur, country, regime) {
|
||||
break
|
||||
}
|
||||
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
|
||||
end = cur
|
||||
}
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// Compile-time assertion that SnapshotHolidayCalendar satisfies
|
||||
// lp.HolidayCalendar.
|
||||
var _ lp.HolidayCalendar = (*SnapshotHolidayCalendar)(nil)
|
||||
32
pkg/litigationplanner/embedded/upc/holidays.json
Normal file
32
pkg/litigationplanner/embedded/upc/holidays.json
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"name": "Neujahr",
|
||||
"country": "DE",
|
||||
"holiday_type": "closure"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"name": "Tag der Arbeit",
|
||||
"country": "DE",
|
||||
"holiday_type": "closure"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"name": "UPC Sommerpause",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-25",
|
||||
"name": "UPC Sommerpause",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"name": "UPC Sommerpause",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
}
|
||||
]
|
||||
11
pkg/litigationplanner/embedded/upc/meta.json
Normal file
11
pkg/litigationplanner/embedded/upc/meta.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "2026-05-26-1-placeholder",
|
||||
"generated_at": "2026-05-26T15:00:00Z",
|
||||
"paliad_commit": "",
|
||||
"source_db_label": "placeholder — operator must run `make snapshot-upc` against prod once mig 134/135 are applied",
|
||||
"rule_count": 2,
|
||||
"proceeding_count": 2,
|
||||
"trigger_event_count": 0,
|
||||
"holiday_count": 5,
|
||||
"court_count": 2
|
||||
}
|
||||
32
pkg/litigationplanner/embedded/upc/proceeding_types.json
Normal file
32
pkg/litigationplanner/embedded/upc/proceeding_types.json
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": 8,
|
||||
"code": "upc.inf.cfi",
|
||||
"name": "Verletzungsverfahren",
|
||||
"name_en": "Infringement Action",
|
||||
"description": "UPC infringement proceedings at first instance.",
|
||||
"jurisdiction": "UPC",
|
||||
"category": "fristenrechner",
|
||||
"default_color": "#3b82f6",
|
||||
"sort_order": 10,
|
||||
"is_active": true,
|
||||
"trigger_event_label_de": null,
|
||||
"trigger_event_label_en": null,
|
||||
"appeal_target": null
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"code": "upc.rev.cfi",
|
||||
"name": "Nichtigkeitsverfahren",
|
||||
"name_en": "Revocation Action",
|
||||
"description": "UPC revocation proceedings at first instance.",
|
||||
"jurisdiction": "UPC",
|
||||
"category": "fristenrechner",
|
||||
"default_color": "#f59e0b",
|
||||
"sort_order": 20,
|
||||
"is_active": true,
|
||||
"trigger_event_label_de": null,
|
||||
"trigger_event_label_en": null,
|
||||
"appeal_target": null
|
||||
}
|
||||
]
|
||||
43
pkg/litigationplanner/embedded/upc/rules.json
Normal file
43
pkg/litigationplanner/embedded/upc/rules.json
Normal file
@@ -0,0 +1,43 @@
|
||||
[
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"proceeding_type_id": 8,
|
||||
"submission_code": "upc.inf.cfi.soc",
|
||||
"name": "Klageerhebung",
|
||||
"name_en": "Statement of Claim",
|
||||
"duration_value": 0,
|
||||
"duration_unit": "months",
|
||||
"sequence_order": 1,
|
||||
"is_spawn": false,
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"priority": "mandatory",
|
||||
"is_court_set": false,
|
||||
"is_bilateral": false,
|
||||
"lifecycle_state": "published"
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"proceeding_type_id": 8,
|
||||
"parent_id": "11111111-1111-1111-1111-111111111111",
|
||||
"submission_code": "upc.inf.cfi.sod",
|
||||
"name": "Klageerwiderung",
|
||||
"name_en": "Statement of Defence",
|
||||
"primary_party": "defendant",
|
||||
"duration_value": 3,
|
||||
"duration_unit": "months",
|
||||
"timing": "after",
|
||||
"rule_code": "UPC.RoP.23.1",
|
||||
"legal_source": "UPC.RoP.23.1",
|
||||
"sequence_order": 2,
|
||||
"is_spawn": false,
|
||||
"is_active": true,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"priority": "mandatory",
|
||||
"is_court_set": false,
|
||||
"is_bilateral": false,
|
||||
"lifecycle_state": "published"
|
||||
}
|
||||
]
|
||||
301
pkg/litigationplanner/embedded/upc/snapshot.go
Normal file
301
pkg/litigationplanner/embedded/upc/snapshot.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package upc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// SnapshotCatalog is the embedded-JSON implementation of lp.Catalog.
|
||||
// All lookups are O(1) on indexed in-memory maps; LookupEvents does a
|
||||
// linear scan of the rule slice (< 100 rows in the UPC corpus, no
|
||||
// index needed).
|
||||
//
|
||||
// ProjectHint is ignored — the snapshot has no project-scoped rules.
|
||||
// applies_to_target (B1) and condition_expr (Phase 2) ride along on
|
||||
// each Rule as ordinary fields; the engine consumes them identically
|
||||
// whether the catalog is paliad-backed or snapshot-backed.
|
||||
type SnapshotCatalog struct {
|
||||
procs []lp.ProceedingType
|
||||
rules []lp.Rule
|
||||
triggerByID map[int64]lp.TriggerEvent
|
||||
rulesByProc map[int][]lp.Rule
|
||||
ruleByID map[uuid.UUID]lp.Rule
|
||||
procByID map[int]lp.ProceedingType
|
||||
procByCode map[string]lp.ProceedingType
|
||||
rulesByTriggr map[int64][]lp.Rule
|
||||
}
|
||||
|
||||
// NewCatalog parses the embedded snapshot and returns a ready-to-use
|
||||
// Catalog. Returns an error when the JSON is missing or malformed
|
||||
// (e.g. snapshot never generated, or stale relative to the package
|
||||
// types).
|
||||
func NewCatalog() (*SnapshotCatalog, error) {
|
||||
var procs []lp.ProceedingType
|
||||
if err := readJSON("proceeding_types.json", &procs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rules []lp.Rule
|
||||
if err := readJSON("rules.json", &rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var triggers []lp.TriggerEvent
|
||||
if err := readJSON("trigger_events.json", &triggers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &SnapshotCatalog{
|
||||
procs: procs,
|
||||
rules: rules,
|
||||
triggerByID: make(map[int64]lp.TriggerEvent, len(triggers)),
|
||||
rulesByProc: make(map[int][]lp.Rule),
|
||||
ruleByID: make(map[uuid.UUID]lp.Rule, len(rules)),
|
||||
procByID: make(map[int]lp.ProceedingType, len(procs)),
|
||||
procByCode: make(map[string]lp.ProceedingType, len(procs)),
|
||||
rulesByTriggr: make(map[int64][]lp.Rule),
|
||||
}
|
||||
for _, p := range procs {
|
||||
c.procByID[p.ID] = p
|
||||
c.procByCode[p.Code] = p
|
||||
}
|
||||
for _, r := range rules {
|
||||
c.ruleByID[r.ID] = r
|
||||
if r.ProceedingTypeID != nil {
|
||||
c.rulesByProc[*r.ProceedingTypeID] = append(c.rulesByProc[*r.ProceedingTypeID], r)
|
||||
}
|
||||
if r.TriggerEventID != nil {
|
||||
c.rulesByTriggr[*r.TriggerEventID] = append(c.rulesByTriggr[*r.TriggerEventID], r)
|
||||
}
|
||||
}
|
||||
for _, t := range triggers {
|
||||
c.triggerByID[t.ID] = t
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// LoadProceeding returns the proceeding-type metadata + rules. The
|
||||
// ProjectHint is ignored on the snapshot side (no projects).
|
||||
func (c *SnapshotCatalog) LoadProceeding(_ context.Context, code string, _ lp.ProjectHint) (*lp.ProceedingType, []lp.Rule, error) {
|
||||
p, ok := c.procByCode[code]
|
||||
if !ok {
|
||||
return nil, nil, lp.ErrUnknownProceedingType
|
||||
}
|
||||
// Return a defensive copy of the rule slice so callers can sort /
|
||||
// mutate without leaking back into the cache.
|
||||
src := c.rulesByProc[p.ID]
|
||||
dst := make([]lp.Rule, len(src))
|
||||
copy(dst, src)
|
||||
return &p, dst, nil
|
||||
}
|
||||
|
||||
// LoadProceedingByID is the resolver used by CalculateRule.
|
||||
func (c *SnapshotCatalog) LoadProceedingByID(_ context.Context, id int) (*lp.ProceedingType, error) {
|
||||
p, ok := c.procByID[id]
|
||||
if !ok {
|
||||
return nil, lp.ErrUnknownProceedingType
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// LoadRuleByID resolves a rule UUID to the rule row.
|
||||
func (c *SnapshotCatalog) LoadRuleByID(_ context.Context, ruleID string) (*lp.Rule, error) {
|
||||
id, err := uuid.Parse(ruleID)
|
||||
if err != nil {
|
||||
return nil, lp.ErrUnknownRule
|
||||
}
|
||||
r, ok := c.ruleByID[id]
|
||||
if !ok {
|
||||
return nil, lp.ErrUnknownRule
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode).
|
||||
func (c *SnapshotCatalog) LoadRuleByCode(_ context.Context, proceedingCode, submissionCode string) (*lp.Rule, *lp.ProceedingType, error) {
|
||||
p, ok := c.procByCode[proceedingCode]
|
||||
if !ok {
|
||||
return nil, nil, lp.ErrUnknownProceedingType
|
||||
}
|
||||
for _, r := range c.rulesByProc[p.ID] {
|
||||
if r.SubmissionCode != nil && *r.SubmissionCode == submissionCode {
|
||||
rr := r
|
||||
pp := p
|
||||
return &rr, &pp, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, lp.ErrUnknownRule
|
||||
}
|
||||
|
||||
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules.
|
||||
func (c *SnapshotCatalog) LoadRulesByTriggerEvent(_ context.Context, triggerEventID int64) ([]lp.Rule, error) {
|
||||
src := c.rulesByTriggr[triggerEventID]
|
||||
dst := make([]lp.Rule, len(src))
|
||||
copy(dst, src)
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// LoadTriggerEventsByIDs returns trigger-event rows for the given IDs.
|
||||
func (c *SnapshotCatalog) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]lp.TriggerEvent, error) {
|
||||
out := make(map[int64]lp.TriggerEvent, len(ids))
|
||||
for _, id := range ids {
|
||||
if t, ok := c.triggerByID[id]; ok {
|
||||
out[id] = t
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LookupEvents runs the multi-axis filter + depth walk against the
|
||||
// in-memory rule slice. Mirrors the paliad-side semantics: unknown
|
||||
// axis values fall through as "no filter on this axis"; anchors are
|
||||
// depth=1, walked-in children are depth=2+; results ordered by
|
||||
// (proceeding_type_id, sequence_order).
|
||||
func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
|
||||
// Validate axes; unknown values reset to empty (no filter).
|
||||
jurisdiction := axes.Jurisdiction
|
||||
if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" &&
|
||||
jurisdiction != "EPA" && jurisdiction != "DPMA" {
|
||||
jurisdiction = ""
|
||||
}
|
||||
party := axes.Party
|
||||
if party != "" && !lp.IsValidPrimaryParty(party) {
|
||||
party = ""
|
||||
}
|
||||
appealTarget := axes.AppealTarget
|
||||
if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) {
|
||||
appealTarget = ""
|
||||
}
|
||||
|
||||
// First pass: find anchor matches (rules that satisfy every
|
||||
// non-zero axis directly).
|
||||
anchors := make(map[uuid.UUID]bool, len(c.rules))
|
||||
for _, r := range c.rules {
|
||||
if r.ProceedingTypeID == nil {
|
||||
continue
|
||||
}
|
||||
p := c.procByID[*r.ProceedingTypeID]
|
||||
if jurisdiction != "" && (p.Jurisdiction == nil || *p.Jurisdiction != jurisdiction) {
|
||||
continue
|
||||
}
|
||||
if axes.ProceedingTypeID != nil && *r.ProceedingTypeID != *axes.ProceedingTypeID {
|
||||
continue
|
||||
}
|
||||
if party != "" && (r.PrimaryParty == nil || *r.PrimaryParty != party) {
|
||||
continue
|
||||
}
|
||||
// EventCategoryID axis: the embedded snapshot doesn't carry
|
||||
// the deadline_concept_event_types junction (only paliad has
|
||||
// it). When EventCategoryID is set, we conservatively return
|
||||
// no matches — youpc.org doesn't use this axis today. Future
|
||||
// snapshot generations can add a concept→category index if
|
||||
// needed.
|
||||
if axes.EventCategoryID != nil {
|
||||
continue
|
||||
}
|
||||
if appealTarget != "" {
|
||||
found := false
|
||||
for _, t := range r.AppliesToTarget {
|
||||
if t == appealTarget {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
anchors[r.ID] = true
|
||||
}
|
||||
|
||||
// Second pass: depth walk. Expand anchors → their immediate
|
||||
// children (parent_id ∈ matched). Iterate to fixpoint for
|
||||
// EventLookupDepthAllFollowing; stop after one pass for
|
||||
// EventLookupDepthNext.
|
||||
matched := make(map[uuid.UUID]bool, len(anchors))
|
||||
for id := range anchors {
|
||||
matched[id] = true
|
||||
}
|
||||
if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
|
||||
for {
|
||||
grew := false
|
||||
for _, r := range c.rules {
|
||||
if matched[r.ID] {
|
||||
continue
|
||||
}
|
||||
if r.ParentID == nil {
|
||||
continue
|
||||
}
|
||||
if matched[*r.ParentID] {
|
||||
matched[r.ID] = true
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
if !grew || depth == lp.EventLookupDepthNext {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute depth from anchor: walk parent_id chain until we hit
|
||||
// an anchor.
|
||||
depths := make(map[uuid.UUID]int, len(matched))
|
||||
for id := range matched {
|
||||
if anchors[id] {
|
||||
depths[id] = 1
|
||||
continue
|
||||
}
|
||||
// Walk up.
|
||||
d := 1
|
||||
cur := id
|
||||
maxIter := len(matched) + 1
|
||||
for i := 0; i < maxIter; i++ {
|
||||
r, ok := c.ruleByID[cur]
|
||||
if !ok || r.ParentID == nil {
|
||||
break
|
||||
}
|
||||
d++
|
||||
cur = *r.ParentID
|
||||
if anchors[cur] {
|
||||
break
|
||||
}
|
||||
}
|
||||
depths[id] = d
|
||||
}
|
||||
|
||||
// Compose output, ordered by (proceeding_type_id, sequence_order)
|
||||
// via the catalog's rule slice ordering.
|
||||
out := make([]lp.EventMatch, 0, len(matched))
|
||||
for _, r := range c.rules {
|
||||
if !matched[r.ID] {
|
||||
continue
|
||||
}
|
||||
var parentRuleID *uuid.UUID
|
||||
if r.ParentID != nil && matched[*r.ParentID] {
|
||||
p := *r.ParentID
|
||||
parentRuleID = &p
|
||||
}
|
||||
proc := lp.ProceedingType{}
|
||||
if r.ProceedingTypeID != nil {
|
||||
proc = c.procByID[*r.ProceedingTypeID]
|
||||
}
|
||||
out = append(out, lp.EventMatch{
|
||||
Rule: r,
|
||||
ProceedingType: proc,
|
||||
Priority: r.Priority,
|
||||
DepthFromAnchor: depths[r.ID],
|
||||
ParentRuleID: parentRuleID,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Compile-time assertion that SnapshotCatalog satisfies lp.Catalog.
|
||||
var _ lp.Catalog = (*SnapshotCatalog)(nil)
|
||||
|
||||
// ErrSnapshotEmpty is returned by NewCatalog when the embedded files
|
||||
// parse but the corpus is empty (zero proceedings) — almost always a
|
||||
// sign that the snapshot has never been generated.
|
||||
var ErrSnapshotEmpty = fmt.Errorf("upc snapshot is empty — run cmd/gen-upc-snapshot")
|
||||
215
pkg/litigationplanner/embedded/upc/snapshot_test.go
Normal file
215
pkg/litigationplanner/embedded/upc/snapshot_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package upc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// TestSnapshotMeta loads + parses meta.json and asserts the version
|
||||
// + non-zero counts. Until the operator regenerates the snapshot the
|
||||
// placeholder shipped with Slice C must still parse cleanly.
|
||||
func TestSnapshotMeta(t *testing.T) {
|
||||
meta, err := LoadMeta()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMeta: %v", err)
|
||||
}
|
||||
if meta.Version == "" {
|
||||
t.Error("meta.Version is empty")
|
||||
}
|
||||
if meta.ProceedingCount <= 0 {
|
||||
t.Errorf("meta.ProceedingCount = %d, want > 0", meta.ProceedingCount)
|
||||
}
|
||||
if meta.RuleCount <= 0 {
|
||||
t.Errorf("meta.RuleCount = %d, want > 0", meta.RuleCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCatalog smoke-tests the embedded catalog's lookups
|
||||
// against the shipped placeholder. After operator regeneration the
|
||||
// asserts on per-row content still hold because they pin the wire
|
||||
// shape (proceedingType.Code, rule resolution by code, lookup-events
|
||||
// jurisdiction filter).
|
||||
func TestSnapshotCatalog(t *testing.T) {
|
||||
cat, err := NewCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCatalog: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("LoadProceeding upc.inf.cfi", func(t *testing.T) {
|
||||
pt, rules, err := cat.LoadProceeding(ctx, "upc.inf.cfi", lp.ProjectHint{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProceeding: %v", err)
|
||||
}
|
||||
if pt.Code != "upc.inf.cfi" {
|
||||
t.Errorf("pt.Code = %q, want upc.inf.cfi", pt.Code)
|
||||
}
|
||||
if pt.Jurisdiction == nil || *pt.Jurisdiction != "UPC" {
|
||||
t.Errorf("pt.Jurisdiction = %v, want UPC", pt.Jurisdiction)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
t.Error("LoadProceeding returned zero rules — snapshot empty?")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LoadProceeding unknown code returns ErrUnknownProceedingType", func(t *testing.T) {
|
||||
_, _, err := cat.LoadProceeding(ctx, "no.such.code", lp.ProjectHint{})
|
||||
if err != lp.ErrUnknownProceedingType {
|
||||
t.Errorf("got %v, want ErrUnknownProceedingType", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LookupEvents UPC all-following returns the whole UPC corpus", func(t *testing.T) {
|
||||
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected non-empty UPC corpus")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
|
||||
t.Errorf("non-UPC row leaked: %v", m.ProceedingType.Code)
|
||||
}
|
||||
if m.DepthFromAnchor < 1 {
|
||||
t.Errorf("depth = %d, want >= 1", m.DepthFromAnchor)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LookupEvents party=defendant scopes anchors", func(t *testing.T) {
|
||||
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
Party: "defendant",
|
||||
}, lp.EventLookupDepthNext)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// Anchor rows (depth=1) must all be defendant.
|
||||
anyDefendant := false
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
|
||||
t.Errorf("anchor row %s is not defendant: %v", m.Rule.Name, m.Rule.PrimaryParty)
|
||||
}
|
||||
anyDefendant = true
|
||||
}
|
||||
if !anyDefendant {
|
||||
t.Log("no defendant rules in the placeholder corpus — operator should regenerate the snapshot")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSnapshotEngineCompute runs the litigationplanner engine against
|
||||
// the embedded snapshot end-to-end. Ensures the wiring between the
|
||||
// snapshot Catalog / HolidayCalendar / CourtRegistry + the engine
|
||||
// produces a non-empty timeline.
|
||||
func TestSnapshotEngineCompute(t *testing.T) {
|
||||
cat, err := NewCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCatalog: %v", err)
|
||||
}
|
||||
hc, err := NewHolidayCalendar()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHolidayCalendar: %v", err)
|
||||
}
|
||||
cr, err := NewCourtRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCourtRegistry: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-01-15", lp.CalcOptions{}, cat, hc, cr)
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
if timeline == nil {
|
||||
t.Fatal("Calculate returned nil timeline")
|
||||
}
|
||||
if timeline.ProceedingType != "upc.inf.cfi" {
|
||||
t.Errorf("timeline.ProceedingType = %q, want upc.inf.cfi", timeline.ProceedingType)
|
||||
}
|
||||
if len(timeline.Deadlines) == 0 {
|
||||
t.Error("timeline has zero deadlines — snapshot empty?")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotHolidayCalendar smoke-tests the embedded calendar.
|
||||
// Pins core semantics: weekends are non-working; holidays at
|
||||
// matching country/regime are non-working; mismatches don't fire.
|
||||
func TestSnapshotHolidayCalendar(t *testing.T) {
|
||||
hc, err := NewHolidayCalendar()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHolidayCalendar: %v", err)
|
||||
}
|
||||
|
||||
// 2026-01-03 is a Saturday — weekend, non-working regardless of
|
||||
// country/regime.
|
||||
sat := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
|
||||
if !hc.IsNonWorkingDay(sat, "DE", "UPC") {
|
||||
t.Error("Saturday should be non-working")
|
||||
}
|
||||
|
||||
// 2026-01-01 is Neujahr (DE closure) — non-working when country=DE.
|
||||
newYear := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if !hc.IsNonWorkingDay(newYear, "DE", "UPC") {
|
||||
t.Error("Neujahr should be non-working for DE")
|
||||
}
|
||||
|
||||
// 2026-01-05 is a Monday — working (not in holidays, not weekend).
|
||||
mon := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
|
||||
if hc.IsNonWorkingDay(mon, "DE", "UPC") {
|
||||
t.Error("Monday 2026-01-05 should be working")
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDays from a Saturday should land on Monday.
|
||||
adj, _, was := hc.AdjustForNonWorkingDays(sat, "DE", "UPC")
|
||||
if !was {
|
||||
t.Error("expected adjustment for Saturday")
|
||||
}
|
||||
if adj.Weekday() != time.Monday {
|
||||
t.Errorf("adjusted weekday = %v, want Monday", adj.Weekday())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCourtRegistry pins (country, regime) resolution.
|
||||
func TestSnapshotCourtRegistry(t *testing.T) {
|
||||
cr, err := NewCourtRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCourtRegistry: %v", err)
|
||||
}
|
||||
|
||||
t.Run("empty courtID falls back to defaults", func(t *testing.T) {
|
||||
c, r, err := cr.CountryRegime("", "DE", "UPC")
|
||||
if err != nil {
|
||||
t.Fatalf("CountryRegime: %v", err)
|
||||
}
|
||||
if c != "DE" || r != "UPC" {
|
||||
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("known UPC court resolves", func(t *testing.T) {
|
||||
c, r, err := cr.CountryRegime("upc-ld-munich", "DE", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CountryRegime: %v", err)
|
||||
}
|
||||
if c != "DE" || r != "UPC" {
|
||||
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown court returns error", func(t *testing.T) {
|
||||
_, _, err := cr.CountryRegime("not-a-court", "DE", "UPC")
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown court")
|
||||
}
|
||||
})
|
||||
}
|
||||
1
pkg/litigationplanner/embedded/upc/trigger_events.json
Normal file
1
pkg/litigationplanner/embedded/upc/trigger_events.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -126,6 +126,25 @@ func Calculate(
|
||||
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
|
||||
}
|
||||
|
||||
// AppealTarget filter (Slice B1, m/paliad#124 §18.1). When set,
|
||||
// keep only rules whose AppliesToTarget contains the requested
|
||||
// slug. Unknown slugs short-circuit to no-op (defensive: a stale
|
||||
// frontend chip shouldn't break the render). Empty AppliesToTarget
|
||||
// on a rule means "doesn't belong to an appeal target" — such a
|
||||
// rule is suppressed under any non-empty AppealTarget filter.
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
filtered := make([]Rule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
for _, t := range r.AppliesToTarget {
|
||||
if t == opts.AppealTarget {
|
||||
filtered = append(filtered, r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rules = filtered
|
||||
}
|
||||
|
||||
// ruleByID lets the conditional-rendering branches resolve a parent
|
||||
// rule's display fields (submission_code, name, name_en) for the
|
||||
// "abhängig von <ParentRuleName>" chip without re-scanning the rules
|
||||
@@ -552,6 +571,21 @@ func Calculate(
|
||||
if pickedProceeding.TriggerEventLabelEN != nil {
|
||||
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
|
||||
}
|
||||
// t-paliad-301 / m/paliad#132 Bug B — appeal_target-driven trigger
|
||||
// label. When the request narrows to a specific appeal target, the
|
||||
// "Auslösendes Ereignis" label describes the underlying decision
|
||||
// (Endentscheidung / Kostenentscheidung / Anordnung /
|
||||
// Schadensbemessung / Bucheinsicht) rather than the appeal
|
||||
// proceeding itself. Overrides the proceeding's own
|
||||
// trigger_event_label set above.
|
||||
if opts.AppealTarget != "" {
|
||||
if de := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de"); de != "" {
|
||||
resp.TriggerEventLabel = de
|
||||
}
|
||||
if en := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en"); en != "" {
|
||||
resp.TriggerEventLabelEN = en
|
||||
}
|
||||
}
|
||||
if hasSubTrackNote {
|
||||
resp.ContextualNote = subTrackNote.NoteDE
|
||||
resp.ContextualNoteEN = subTrackNote.NoteEN
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
||||
@@ -149,6 +150,13 @@ type Rule struct {
|
||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||
// t-paliad-265). NULL = no caret affordance (default).
|
||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||
|
||||
// AppliesToTarget is the per-rule applies-to set for the unified
|
||||
// UPC Berufung proceeding type (Slice B1, mig 134, m/paliad#124
|
||||
// §18.1). Each element ∈ AppealTargets. NULL on rules outside
|
||||
// the appeal proceeding. The engine filters by this when
|
||||
// CalcOptions.AppealTarget is set.
|
||||
AppliesToTarget pq.StringArray `db:"applies_to_target" json:"appliesToTarget,omitempty"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR
|
||||
@@ -171,6 +179,71 @@ type ProceedingType struct {
|
||||
// that fires when no rule has IsRootEvent=true.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
|
||||
// AppealTarget is the top-level appeal-target marker (Slice B1, mig
|
||||
// 134). NULL on non-appeal proceedings. Reserved for future variants
|
||||
// — today the unified upc.apl row has this NULL (per-rule targets
|
||||
// live on Rule.AppliesToTarget).
|
||||
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
|
||||
|
||||
// Role label overrides (t-paliad-301 / m/paliad#132, mig 137).
|
||||
// NULL = renderer falls back to the language-default labels
|
||||
// ("Klägerseite" / "Beklagtenseite" / "Claimant side" / "Defendant side").
|
||||
// Set on proceedings where the role-naming diverges from the
|
||||
// claimant/defendant default (Appeal → Berufungskläger /
|
||||
// Berufungsbeklagter; Revocation → Antragsteller /
|
||||
// Antragsgegner Nichtigkeit; EPA Opposition → Einsprechende(r) /
|
||||
// Patentinhaber(in)).
|
||||
RoleProactiveLabelDE *string `db:"role_proactive_label_de" json:"role_proactive_label_de,omitempty"`
|
||||
RoleProactiveLabelEN *string `db:"role_proactive_label_en" json:"role_proactive_label_en,omitempty"`
|
||||
RoleReactiveLabelDE *string `db:"role_reactive_label_de" json:"role_reactive_label_de,omitempty"`
|
||||
RoleReactiveLabelEN *string `db:"role_reactive_label_en" json:"role_reactive_label_en,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerEventLabelForAppealTarget returns the per-target
|
||||
// "Auslösendes Ereignis" label for the unified UPC Berufung
|
||||
// proceeding (t-paliad-301 / m/paliad#132 Bug B). The trigger event
|
||||
// for an appeal is the underlying decision, not the appeal
|
||||
// proceeding itself — these labels override the proceeding's own
|
||||
// trigger_event_label when appeal_target is set.
|
||||
//
|
||||
// lang ∈ {"de", "en"}; any other value falls through to "de" so the
|
||||
// caller never gets an empty string.
|
||||
//
|
||||
// Returns empty when target is empty / unknown (caller must fall
|
||||
// back to the proceeding's own trigger_event_label).
|
||||
func TriggerEventLabelForAppealTarget(target, lang string) string {
|
||||
if lang != "en" {
|
||||
lang = "de"
|
||||
}
|
||||
switch target {
|
||||
case AppealTargetEndentscheidung:
|
||||
if lang == "en" {
|
||||
return "Final decision (R.118)"
|
||||
}
|
||||
return "Endentscheidung (R.118)"
|
||||
case AppealTargetKostenentscheidung:
|
||||
if lang == "en" {
|
||||
return "Cost decision"
|
||||
}
|
||||
return "Kostenentscheidung"
|
||||
case AppealTargetAnordnung:
|
||||
if lang == "en" {
|
||||
return "Order"
|
||||
}
|
||||
return "Anordnung"
|
||||
case AppealTargetSchadensbemessung:
|
||||
if lang == "en" {
|
||||
return "Damages-assessment decision"
|
||||
}
|
||||
return "Entscheidung im Schadensbemessungsverfahren"
|
||||
case AppealTargetBucheinsicht:
|
||||
if lang == "en" {
|
||||
return "Book-inspection order"
|
||||
}
|
||||
return "Anordnung der Bucheinsicht"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AdjustmentReason describes why a date was rolled forward / backward
|
||||
@@ -253,6 +326,14 @@ type CalcOptions struct {
|
||||
IncludeHidden bool
|
||||
|
||||
ProjectHint ProjectHint
|
||||
|
||||
// AppealTarget narrows the timeline to rules whose AppliesToTarget
|
||||
// contains the requested slug. Empty = no filter. Set to one of
|
||||
// AppealTargets for the unified UPC Berufung picker (Slice B1,
|
||||
// m/paliad#124 §18.1). Unknown slugs are silently dropped (no
|
||||
// filter applied) so a stale frontend chip doesn't break the
|
||||
// timeline render — see IsValidAppealTarget.
|
||||
AppealTarget string
|
||||
}
|
||||
|
||||
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
||||
@@ -404,6 +485,76 @@ type FristenrechnerType struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// EventLookupAxes carries the optional filter axes for
|
||||
// Catalog.LookupEvents (Slice B2, m/paliad#124 §18.2). All fields are
|
||||
// optional; the empty value (or nil pointer) is "no filter on this
|
||||
// axis". When multiple axes are set the catalog applies them as AND —
|
||||
// a rule must match every non-zero axis to be returned. An axis set
|
||||
// to an unknown value (jurisdiction="XX", party="foo") is treated the
|
||||
// same as "no filter on this axis" so a stale frontend doesn't
|
||||
// silently drop the entire result set.
|
||||
//
|
||||
// AppealTarget narrows to rules whose AppliesToTarget contains the
|
||||
// requested slug (same semantic as CalcOptions.AppealTarget). Useful
|
||||
// for the unified UPC Berufung lookup.
|
||||
type EventLookupAxes struct {
|
||||
// Jurisdiction filters by paliad.proceeding_types.jurisdiction
|
||||
// ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any.
|
||||
Jurisdiction string
|
||||
// ProceedingTypeID narrows to one proceeding. nil = any.
|
||||
ProceedingTypeID *int
|
||||
// Party filters by paliad.deadline_rules.primary_party
|
||||
// ("claimant" | "defendant" | "court" | "both"). Empty = any.
|
||||
// Validated against PrimaryParties before the SQL pass; unknown
|
||||
// values fall through as "no filter".
|
||||
Party string
|
||||
// EventCategoryID narrows to rules associated with one
|
||||
// event_categories row via the
|
||||
// deadline_concept_event_types junction. nil = any.
|
||||
EventCategoryID *uuid.UUID
|
||||
// AppealTarget filters by Rule.AppliesToTarget containing the
|
||||
// requested slug (e.g. "endentscheidung"). Empty = any.
|
||||
// Validated against AppealTargets before the SQL pass.
|
||||
AppealTarget string
|
||||
}
|
||||
|
||||
// EventLookupDepth controls the sequence-depth of the returned events.
|
||||
type EventLookupDepth string
|
||||
|
||||
const (
|
||||
// EventLookupDepthNext returns immediate children of the matched
|
||||
// anchor (1 hop downstream via parent_id). Useful for "what comes
|
||||
// next from this point?" queries.
|
||||
EventLookupDepthNext EventLookupDepth = "next"
|
||||
// EventLookupDepthAllFollowing returns the entire downstream
|
||||
// chain (parent_id walk to leaves). Useful for "show me the
|
||||
// whole sequence from here onward" queries.
|
||||
EventLookupDepthAllFollowing EventLookupDepth = "all-following"
|
||||
)
|
||||
|
||||
// EventMatch is one result row from Catalog.LookupEvents.
|
||||
type EventMatch struct {
|
||||
// Rule carries the full deadline-rule row including parent_id,
|
||||
// duration_value/_unit, condition_expr, applies_to_target, etc.
|
||||
Rule Rule `json:"rule"`
|
||||
// ProceedingType is the owning proceeding metadata. Lets the
|
||||
// frontend render the "from <proceeding>" badge without a second
|
||||
// roundtrip.
|
||||
ProceedingType ProceedingType `json:"proceedingType"`
|
||||
// Priority surfaces Rule.Priority at the top level for
|
||||
// convenience — the four-value vocab (mandatory / recommended /
|
||||
// optional / informational).
|
||||
Priority string `json:"priority"`
|
||||
// DepthFromAnchor is 1 for the immediate match, 2+ for deeper
|
||||
// descendants returned under EventLookupDepthAllFollowing.
|
||||
// Always >= 1 for any returned row.
|
||||
DepthFromAnchor int `json:"depthFromAnchor"`
|
||||
// ParentRuleID is the parent rule's UUID when that parent is
|
||||
// itself in the returned result set (so the frontend can render
|
||||
// a tree). nil when the parent is outside the returned set.
|
||||
ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||
// whose semantic anchor is an event rather than a parent rule (the
|
||||
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
|
||||
@@ -426,3 +577,96 @@ var (
|
||||
ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
||||
ErrUnknownRule = errors.New("unknown rule")
|
||||
)
|
||||
|
||||
// AppealTarget* are the canonical slugs for the unified UPC Berufung
|
||||
// proceeding type's appeal-target discriminator (Slice B1, m/paliad#124
|
||||
// §18.1). The verfahrensablauf picker renders one "Berufung" entry;
|
||||
// the user then picks one of these five targets and the engine filters
|
||||
// rules whose AppliesToTarget contains the requested slug.
|
||||
//
|
||||
// Schadensbemessung + Bucheinsicht have no rule rows in migration 134;
|
||||
// per m's 2026-05-26 decision they are distinct from the merits track
|
||||
// and their rule sets will be seeded in a follow-up slice (paired with
|
||||
// t-paliad-193 orphan-concept-seed or editorial via /admin/rules).
|
||||
// CalcOptions.AppealTarget="schadensbemessung" or "bucheinsicht"
|
||||
// currently returns an empty timeline.
|
||||
const (
|
||||
AppealTargetEndentscheidung = "endentscheidung"
|
||||
AppealTargetKostenentscheidung = "kostenentscheidung"
|
||||
AppealTargetAnordnung = "anordnung"
|
||||
AppealTargetSchadensbemessung = "schadensbemessung"
|
||||
AppealTargetBucheinsicht = "bucheinsicht"
|
||||
)
|
||||
|
||||
// AppealTargets is the canonical ordered list for UI chip rendering +
|
||||
// validation. Order matches the design doc + the frontend's i18n key
|
||||
// ordering — do not reorder without coordinating with the chip-group
|
||||
// renderer.
|
||||
var AppealTargets = []string{
|
||||
AppealTargetEndentscheidung,
|
||||
AppealTargetKostenentscheidung,
|
||||
AppealTargetAnordnung,
|
||||
AppealTargetSchadensbemessung,
|
||||
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
|
||||
// dropped (no filter applied) rather than producing an error, so a
|
||||
// stale frontend chip doesn't break the timeline render.
|
||||
func IsValidAppealTarget(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
for _, t := range AppealTargets {
|
||||
if t == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user