Files
paliad/docs/design-courts-per-country-holidays-2026-05-05.md
m bf06499d9c 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.
2026-05-05 23:51:47 +02:00

16 KiB
Raw Permalink Blame History

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 existsinternal/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

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 courtcourts.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

// 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

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_BGHde-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.jurisdictionproceeding_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 6398 and 116130.
  • internal/services/fristenrechner.go:116 — current Calculate signature.