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.
16 KiB
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:
- Holidays are scoped per country (
paliad.holidays.countryalready exists). - A new
paliad.courtsentity carriescountryand is FK'd from anything that needs jurisdiction-aware deadline math. - The Fristenrechner takes a
court_id(not acountry_codedirectly), resolves court → country, and gatesIsNonWorkingDayon that country's holidays. - 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 viainformation_schema.columns. m's claim "(already shaped this way)" stands.paliad.courts— does not exist. The schema hasholidays,proceeding_types,deadline_concepts,deadline_rules,event_types,projectsetc., but nocourtstable. Confirmed viainformation_schema.tables. The migration must create it.paliad.proceeding_types.jurisdiction— exists as atextcolumn 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 newcourts.countrywill answer "which national holiday calendar applies". The two are orthogonal:UPC_INFhasjurisdiction='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 toregimein 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.gocarries 41 hand-curatedCourtentries (Gerichtsverzeichnis knowledge tool) with stableID(kebab-case, e.g.upc-ld-paris,upc-cd-munich,de-bgh),Country(ISO-3166 alpha-2), andType(UPC-LD,DE-BGH, …). These ARE the seeds forpaliad.courts. No new curation work needed for the initial migration. HolidayService.IsNonWorkingDay(date time.Time) bool— current signature, no country/court param. Lives atinternal/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 inloadYear(internal/services/holidays.go:92) so a misconfigured DB never silently drops Christmas. Under per-country holidays, this merge must become country-conditional.Holidaycache struct drops theCountryfield —dbHolidayreads it but the publicHoliday(built atinternal/services/holidays.go:77) doesn't carry it forward. The cache shape must growCountryfor per-country lookup to work without re-querying.FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts)— current signature atinternal/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
courtfield (perdocs/design-data-model-v2.md, theverfahren-typed projekte have acourt textcolumn). On migration, attempt to mapcourt→courts.idheuristically (lower-case match, kebabify), backfillcourt_id, leavecourttext in place for legacy reads, and gate the Fristenrechner picker on the new FK only. deadlinesrows — currently no court FK. Addcourt_idnullable, 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:
loadYearadds acountry = ANY($2)filter. The cache key becomes(year, country)— or, slightly slicker, the cache stays keyed byyearbut theHolidaystruct grows aCountryfield 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 becomesif countryCode == "DE" { merge(...) }. Belt-and-braces: also seedpaliad.holidaysproperly 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:
- 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. - Resolve
courtID → countryCodeviapaliad.courts. - Pass
countryCodeto everyIsNonWorkingDay/AdjustForNonWorkingDayscall 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
— m: "We can't map jurisdictions to proceeding types" (2026-05-05 18:51).jurisdictions text[]column on proceeding_types— same.proceeding_type_jurisdictionsjoin tableHard-coded— same.switch proceedingCodein Go- Per-court rule overrides — already handled by the t-131
deadline_concepts.per_contextjsonb. Out of scope here; lean on it if a court has a non-standard duration. Don't replicate that mechanism. - Promoting the static
[]Courtslice ininternal/handlers/courts.goto DB-authoritative — orthogonal. The static slice continues to back the Gerichtsverzeichnis page;paliad.courtsis the deadline-computation slice. They're sibling artefacts, not master/replica.
Latent bugs to fix as part of this work
loadYeardoesn't filter by country. Today, if anyone seeds a non-DE row intopaliad.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 toloadYear/IsNonWorkingDay.- 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.) paliad.holidayshas 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 apaliad.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.
- Migration
053_courts.up.sql— createpaliad.courts, seed frominternal/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). Addpaliad.countrieslookup with the eight ISO codes paliad needs initially: DE, FR, IT, NL, BE, FI, PT, AT, SI, DK, SE, LU. - Migration
054_holidays_country_fk.up.sql— addholidays.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). - Migration
055_proceedings_court_fk.up.sql— add nullableprojects.court_id REFERENCES courts(id)forverfahren-typed rows; backfill via heuristic match against the legacy free-textcourtcolumn; flag unmapped rows in a\paliad.unmapped_courtsview for manual triage. Don't drop the legacycourttext yet. - HolidayService refactor — grow
Holidaystruct withCountry, change cache shape, add country param toIsNonWorkingDay/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. - FristenrechnerService refactor — add
courtIDparameter toCalculate; resolve court → country at the top of the function; thread country through every helper. - 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. - 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.
- Test coverage — table-driven test in
internal/services/holidays_test.gocovering: 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
IsNonWorkingDayexcludes them. - t-paliad-131 —
deadline_concepts.per_contextjsonb already supports per-context overrides; if a court demands a non-standard duration, use that mechanism rather than a new column oncourts. - 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 seedpaliad.courts.internal/services/holidays.go— current HolidayService; country-blindness lives at lines 63–98 and 116–130.internal/services/fristenrechner.go:116— currentCalculatesignature.