Compare commits
10 Commits
mai/diesel
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cff38ff3c | |||
| 46b58dcf41 | |||
| 9da4715137 | |||
| 16ec8c490a | |||
| f49c804ddd | |||
| 5901d40b79 | |||
| c767b61a8a | |||
| 2a56b7817c | |||
| 75833082fc | |||
| ce28ea972e |
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))
|
||||
}
|
||||
@@ -1449,4 +1449,170 @@ 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.*
|
||||
|
||||
@@ -462,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",
|
||||
@@ -3567,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,17 +54,13 @@ 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.unified",
|
||||
"de.inf.olg",
|
||||
@@ -73,6 +71,44 @@ 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)
|
||||
@@ -105,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");
|
||||
@@ -117,11 +148,31 @@ function writeSideToURL(s: Side) {
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
// 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
|
||||
@@ -432,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 });
|
||||
|
||||
@@ -501,8 +557,8 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
syncAppealTargetRowVisibility();
|
||||
applyRoleLabels(selectedType);
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
@@ -510,23 +566,6 @@ 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");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -727,10 +766,8 @@ 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();
|
||||
|
||||
@@ -745,16 +782,6 @@ function initPerspectiveControls() {
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -1190,10 +1190,6 @@ export type I18nKey =
|
||||
| "deadlines.appeal_target.kostenentscheidung"
|
||||
| "deadlines.appeal_target.label"
|
||||
| "deadlines.appeal_target.schadensbemessung"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
|
||||
@@ -250,23 +250,6 @@ export function renderVerfahrensablauf(): string {
|
||||
</label>
|
||||
</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">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
|
||||
Re-surfaces optional cards the user has previously
|
||||
marked "Überspringen" via the per-card popover.
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -24,8 +24,7 @@ SELECT set_config(
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = true,
|
||||
updated_at = now()
|
||||
SET is_active = true
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
@@ -214,8 +214,7 @@ UPDATE paliad.deadline_rules dr
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false,
|
||||
updated_at = now()
|
||||
SET is_active = false
|
||||
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -40,7 +40,9 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en,
|
||||
appeal_target`
|
||||
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
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestLookupEvents(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=schadensbemessung returns empty (no rules seeded yet)", func(t *testing.T) {
|
||||
t.Run("appeal_target=schadensbemessung returns upc.apl merits rules (mig 138 backfill)", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetSchadensbemessung,
|
||||
@@ -152,8 +152,68 @@ func TestLookupEvents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Errorf("schadensbemessung should be empty until rules seeded; got %d rows", len(matches))
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 merits-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {schadensbemessung}
|
||||
// because R.224 is uniform across substantive R.118 decisions.
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl schadensbemessung rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetSchadensbemessung {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("anchor row %s missing schadensbemessung target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=bucheinsicht returns upc.apl order rules (mig 138 backfill)", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetBucheinsicht,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 order-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {bucheinsicht}
|
||||
// because R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are
|
||||
// uniform across the orders they appeal.
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl bucheinsicht rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetBucheinsicht {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("anchor row %s missing bucheinsicht target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 @@
|
||||
[]
|
||||
@@ -571,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
|
||||
|
||||
@@ -185,6 +185,65 @@ type ProceedingType struct {
|
||||
// — 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
|
||||
|
||||
Reference in New Issue
Block a user