# Courts entity + per-country holiday computation **Task:** t-paliad-122 **Status:** ON-HOLD until trigger condition met (see "When to do it"). Design locked by m on 2026-05-05 18:51; this doc archives the design plus live-codebase findings so a future implementer doesn't have to re-derive them. **Inventor:** cronus (2026-05-05 23:46) ## TL;DR Today, every `paliad` deadline calculation gates on one combined holiday list (DE federal hardcoded + whatever rows happen to be in `paliad.holidays` for that year, regardless of country). That works while every active user is in a German jurisdiction. It silently breaks the moment a UPC LD outside DE (Paris, Helsinki, Milan, Den Haag, …) or an EPO closure-day calendar comes into scope, because the right calendar to apply depends on **the court the proceeding is filed in**, not on the proceeding type. m's locked model: 1. Holidays are scoped per country (`paliad.holidays.country` already exists). 2. A new `paliad.courts` entity carries `country` and is FK'd from anything that needs jurisdiction-aware deadline math. 3. The Fristenrechner takes a `court_id` (not a `country_code` directly), resolves court → country, and gates `IsNonWorkingDay` on that country's holidays. 4. Jurisdiction lives on the **court (forum)**, not on the proceeding type. UPC_INF can sit in München LD (DE), Paris LD (FR) or Helsinki LD (FI) — same proceeding, three calendars. ## What's true today (live-code verification, 2026-05-05) Verified against the worktree at `mai/cronus/inventor-holidays-per` and the `paliad.*` schema on the youpc Supabase. The design rests on these: - **`paliad.holidays.country`** — exists, NOT NULL, default `'DE'`. Verified via `information_schema.columns`. m's claim "(already shaped this way)" stands. - **`paliad.courts`** — does **not** exist. The schema has `holidays`, `proceeding_types`, `deadline_concepts`, `deadline_rules`, `event_types`, `projects` etc., but no `courts` table. Confirmed via `information_schema.tables`. The migration must create it. - **`paliad.proceeding_types.jurisdiction`** — exists as a `text` column with values `'UPC' | 'DE' | 'EPA' | 'DPMA'`. **This is the legal regime, not the country.** It answers "which procedural law applies" (UPC RoP vs. ZPO vs. EPC vs. PatG); the new `courts.country` will answer "which national holiday calendar applies". The two are orthogonal: `UPC_INF` has `jurisdiction='UPC'` (regime) and could be filed in any of nine countries (calendar). The existing column is **not redundant** under the new model and should not be removed; it should be renamed to `regime` in a follow-up if and only if the dual meaning starts confusing future readers (see "Optional rename" below). - **Static court catalog already exists** — `internal/handlers/courts.go` carries 41 hand-curated `Court` entries (Gerichtsverzeichnis knowledge tool) with stable `ID` (kebab-case, e.g. `upc-ld-paris`, `upc-cd-munich`, `de-bgh`), `Country` (ISO-3166 alpha-2), and `Type` (`UPC-LD`, `DE-BGH`, …). These ARE the seeds for `paliad.courts`. No new curation work needed for the initial migration. - **`HolidayService.IsNonWorkingDay(date time.Time) bool`** — current signature, no country/court param. Lives at `internal/services/holidays.go:124`. - **`HolidayService.loadYear(year int)`** — does **not** filter the SQL by country. Returns every row for that year, regardless of country. Latent bug if anyone seeds non-DE rows ahead of the courts entity arriving — they'll silently apply to DE-jurisdictional calculations. See "Latent bugs" below. - **`germanFederalHolidays(year)`** is hardcoded as a merge in `loadYear` (`internal/services/holidays.go:92`) so a misconfigured DB never silently drops Christmas. Under per-country holidays, this merge must become country-conditional. - **`Holiday` cache struct** drops the `Country` field — `dbHoliday` reads it but the public `Holiday` (built at `internal/services/holidays.go:77`) doesn't carry it forward. The cache shape must grow `Country` for per-country lookup to work without re-querying. - **`FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts)`** — current signature at `internal/services/fristenrechner.go:116`, no court param. ## Right data model ### `paliad.courts` ```sql CREATE TABLE paliad.courts ( id text PRIMARY KEY, -- kebab-case stable ID, e.g. 'upc-ld-paris' code text NOT NULL, -- short code, e.g. 'UPC-LD-Paris', for display / log lines name_de text NOT NULL, name_en text NOT NULL, country text NOT NULL, -- ISO-3166 alpha-2, FK target for paliad.holidays.country court_type text NOT NULL, -- 'UPC-LD' | 'UPC-CD' | 'UPC-CoA' | 'UPC-RD' | 'DE-LG' | 'DE-OLG' | 'DE-BGH' | 'DE-BPatG' | 'DE-DPMA' | 'EPA' | 'NAT' parent_id text REFERENCES paliad.courts(id), -- e.g. all UPC LDs → 'upc-cfi'; UPC CoA stands alone is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX courts_country_idx ON paliad.courts(country); CREATE INDEX courts_type_idx ON paliad.courts(court_type); ``` Seeds come straight from `internal/handlers/courts.go` (the 41 entries already there). The Gerichtsverzeichnis handler stays as-is and continues to be the source for the rich `Court{}` metadata (address, phone, languages, source URLs); `paliad.courts` is the **deadline-computation slice** of that catalog and only carries what holiday math needs. Follow-up admin UI can promote courts to authoritative-in-DB later if maintenance friction warrants. `court_type` is denormalised text (matching the existing `CourtTypes` enum in Go) rather than a separate `paliad.court_types` table — the type set is small, stable, and already canonicalised in `internal/handlers/courts.go:CourtTypes`. ### `paliad.holidays.country` keeps its current shape No schema change. We just start writing rows for FR / FI / IT / NL / AT / etc. as those courts come into scope, and the merge of `germanFederalHolidays(year)` becomes conditional on `country='DE'`. ### Court FK on existing rows Touch points in order of impact: - **Project rows that today carry a free-text `court` field** (per `docs/design-data-model-v2.md`, the `verfahren`-typed projekte have a `court text` column). On migration, attempt to map `court` → `courts.id` heuristically (lower-case match, kebabify), backfill `court_id`, leave `court` text in place for legacy reads, and gate the Fristenrechner picker on the new FK only. - **`deadlines` rows** — currently no court FK. Add `court_id` nullable, populate on creation from the parent project's resolved court. Existing rows can stay NULL; the Fristenrechner UI re-resolves at calc time. - **`event_deadlines`, `event_types`, `deadline_rules`** — none gain a court column. The court is a property of the *proceeding the deadline is computed for*, not of the rule template. ## Right service shape ### `HolidayService` becomes country-aware ```go // IsNonWorkingDay returns true on weekends or closure-type holidays // applicable to the given country. countryCode is ISO-3166 alpha-2. func (s *HolidayService) IsNonWorkingDay(date time.Time, countryCode string) bool // AdjustForNonWorkingDays / AdjustForNonWorkingDaysWithReason gain the same param func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, countryCode string) (...) ``` Internally: - `loadYear` adds a `country = ANY($2)` filter. The cache key becomes `(year, country)` — or, slightly slicker, the cache stays keyed by `year` but the `Holiday` struct grows a `Country` field and the lookup helpers filter on it. Cache-by-year wins on hit rate (one fetch per year regardless of how many countries are touched in a request), so prefer growing the struct. - `germanFederalHolidays(year)` merge becomes `if countryCode == "DE" { merge(...) }`. Belt-and-braces: also seed `paliad.holidays` properly with German federal entries via migration so the Go fallback can eventually be retired. - A two-letter country code `""` is treated as a hard error — callers must always say which country they mean. Don't paper over an unknown court with a silent DE default; that's how the bug class this task fixes recurs. ### `FristenrechnerService.Calculate` takes `courtID` ```go func (s *FristenrechnerService) Calculate( ctx context.Context, proceedingCode, triggerDateStr string, courtID string, // NEW — required when proceeding can land in multiple courts; empty for unambiguous DE-only proceedings (BPatG nullity etc.) opts CalcOptions, ) (*UIResponse, error) ``` Resolution path inside `Calculate`: 1. If `courtID == ""`: look up the proceeding type, find its single canonical court (e.g. `DE_NULL_BGH` → `de-bgh`); error if the proceeding can land in multiple courts. 2. Resolve `courtID → countryCode` via `paliad.courts`. 3. Pass `countryCode` to every `IsNonWorkingDay` / `AdjustForNonWorkingDays` call inside the calculator and the rule walker. ### UI: court picker on the Fristenrechner form Show a court dropdown only when the selected proceeding type has more than one possible court (today: every UPC-flavoured proceeding type — `UPC_INF`, `UPC_REV`, `UPC_APP`, `UPC_PI`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_APP_ORDERS`, `UPC_COST_APPEAL`). For DE-only proceedings (`DE_NULL`, `DE_NULL_BGH`, `DE_INF_BGH`, `DPMA_*`, `EPA_*`) keep the form as-is and resolve the court server-side. The picker pulls from `paliad.courts` filtered by court type compatible with the proceeding code, ordered by a hand-curated importance score (HLC offices first → München LD, Düsseldorf LD, Paris LD, …). When `proceeding_types.jurisdiction='UPC'`, valid court types are `UPC-LD | UPC-CD | UPC-CoA | UPC-RD`; when `'DE'`, `DE-LG | DE-OLG | DE-BGH`; etc. The mapping `(jurisdiction → []court_type)` is compact enough to live in Go alongside the existing `CourtTypes`. ## Out of scope - ~~`jurisdictions text[]` column on proceeding_types~~ — m: "We can't map jurisdictions to proceeding types" (2026-05-05 18:51). - ~~`proceeding_type_jurisdictions` join table~~ — same. - ~~Hard-coded `switch proceedingCode` in Go~~ — same. - **Per-court rule overrides** — already handled by the t-131 `deadline_concepts.per_context` jsonb. Out of scope here; lean on it if a court has a non-standard duration. Don't replicate that mechanism. - **Promoting the static `[]Court` slice in `internal/handlers/courts.go` to DB-authoritative** — orthogonal. The static slice continues to back the Gerichtsverzeichnis page; `paliad.courts` is the deadline-computation slice. They're sibling artefacts, not master/replica. ## Latent bugs to fix as part of this work 1. **`loadYear` doesn't filter by country.** Today, if anyone seeds a non-DE row into `paliad.holidays` (e.g. trying out FR holidays for an upcoming Paris LD case), it will silently apply to every DE deadline calculation that year. Fix: SQL-filter by country and require a country argument to `loadYear` / `IsNonWorkingDay`. 2. **Vacation-block walker is country-blind.** `findVacationBlock` (`internal/services/holidays.go:259`) walks across the merged year list with no country filter. After this work, vacation entries have a country too — the walker should only consider vacations for the active country. (Today the only vacations are UPC summer/winter, country-stamped DE in seeded data; harmless until non-DE court vacations land.) 3. **`paliad.holidays` has no FK on country.** A typo'd country code (`'De'` instead of `'DE'`) would silently create an orphan calendar that no court resolves to. Fix: either CHECK constraint listing the alpha-2 codes paliad supports, or a `paliad.countries (code text PRIMARY KEY)` lookup table. Lean toward the lookup table because the courts table will FK to the same set anyway. ## Optional rename — defer `proceeding_types.jurisdiction` → `proceeding_types.regime` would make the dual meaning unambiguous (regime = procedural law, country = holiday calendar). **Not part of this task.** It's a wide rename across migrations, models, services, handlers, and frontend payloads, with no functional benefit until someone gets confused. Leave the column name; document the dual meaning in the column comment when the migration ships. ## When to do it (trigger conditions, restated for the implementer) This task unlocks the moment any of the following becomes real: - A user files a deadline-bearing event in a non-DE UPC LD (Paris, Helsinki, Milan, Den Haag, Brussels, Wien, Lisboa, Ljubljana, Kopenhagen) or in the UPC RD Stockholm. - The user wants EPO closure days modelled separately from German federal holidays (today they overlap heavily but diverge for things like the EPO-internal "shut between Christmas and New Year" rule). - Cross-border practice picks up to the point that a DE firm regularly files in NL LD or FR LD. Until then — don't pre-build. The schema change is small and the verification surface is large; doing it ahead of demand wastes a coder shift and adds another migration to the rollback story without buying anything users feel. ## Implementation outline (when triggered) Order matters — each step is a self-contained, RoP-safe slice that an implementer can ship and merge before starting the next. 1. **Migration `053_courts.up.sql`** — create `paliad.courts`, seed from `internal/handlers/courts.go` (one INSERT per static-list entry, bilingual names from NameDE/NameEN, type from existing Type field, country direct, parent_id linked where the static list expresses hierarchy). Add `paliad.countries` lookup with the eight ISO codes paliad needs initially: DE, FR, IT, NL, BE, FI, PT, AT, SI, DK, SE, LU. 2. **Migration `054_holidays_country_fk.up.sql`** — add `holidays.country REFERENCES countries(code)`. CHECK that every existing row's country is in the lookup (must be true; default 'DE' is already in the seed list). 3. **Migration `055_proceedings_court_fk.up.sql`** — add nullable `projects.court_id REFERENCES courts(id)` for `verfahren`-typed rows; backfill via heuristic match against the legacy free-text `court` column; flag unmapped rows in a `\paliad.unmapped_courts` view for manual triage. Don't drop the legacy `court` text yet. 4. **HolidayService refactor** — grow `Holiday` struct with `Country`, change cache shape, add country param to `IsNonWorkingDay` / `AdjustForNonWorkingDays` / `findVacationBlock`. Keep the German-federal merge but gate on country. Update every call site (deadline_calculator, fristenrechner, deadline_service, event_deadline_service); the compile error is your checklist. 5. **FristenrechnerService refactor** — add `courtID` parameter to `Calculate`; resolve court → country at the top of the function; thread country through every helper. 6. **API + UI** — extend the calc endpoint to accept `courtId`; add the picker to the Fristenrechner form (only renders when proceeding type has multiple compatible courts); persist court choice on calc-result bookmarks. 7. **Seed data for at least one non-DE country** — pick whichever triggered the unlock (FR if Paris LD; NL if Den Haag LD; etc.); seed both public holidays and any UPC vacation entries country-stamped to that code. 8. **Test coverage** — table-driven test in `internal/services/holidays_test.go` covering: DE court → DE holidays; FR court → FR holidays; UPC LD München (DE) → DE holidays; UPC LD Paris (FR) → FR holidays; unknown court → error; missing country argument → error. Plus a Go coverage test asserting every active proceeding type resolves to at least one court. ## Reference - t-paliad-119 — adjustment-reason explainer (what a user sees today when a deadline shifts). - t-paliad-121 — UPC court vacations are informational, not closure-type. Same precedent: vacation entries stay in DB but `IsNonWorkingDay` excludes them. - t-paliad-131 — `deadline_concepts.per_context` jsonb already supports per-context overrides; if a court demands a non-standard duration, use that mechanism rather than a new column on `courts`. - m's design call: 2026-05-05 18:51 — courts own jurisdiction (country), not proceeding types. - `internal/handlers/courts.go` — static court catalog (41 entries) with `(ID, NameDE, NameEN, Country, Type)` ready to seed `paliad.courts`. - `internal/services/holidays.go` — current HolidayService; country-blindness lives at lines 63–98 and 116–130. - `internal/services/fristenrechner.go:116` — current `Calculate` signature.