docs(t-paliad-122): inventor design — courts entity + per-country holidays
Archives m's locked design call (2026-05-05 18:51) plus live-codebase verification: paliad.holidays.country exists per-country; paliad.courts does not (must create); proceeding_types.jurisdiction is regime not country (do not remove); 41 hand-curated courts already in internal/handlers/courts.go ready to seed; HolidayService.loadYear is country-blind today (latent bug); germanFederalHolidays merge is hardcoded (must become country-conditional). Task stays ON-HOLD until a non-DE forum or EPO closure-day calendar comes into scope.
This commit is contained in:
160
docs/design-courts-per-country-holidays-2026-05-05.md
Normal file
160
docs/design-courts-per-country-holidays-2026-05-05.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user