Consultant analysis of paliad's deadline data model per m's framing (court system → proceeding → ordered event types → conditional trigger edges). Maps current 5-table fragmentation, identifies gaps G1–G7, locks 5 structural decisions via AskUserQuestion, proposes target shape with mermaid example, sketches 4-phase additive→cutover migration. Pure design — no code or schema changes in this branch. Locked decisions (verbatim): - Q1: Reuse courts.court_type as court-system identity - Q2: Project IS proceeding instance (sub-projects when needed) - Q3: Separate proceeding_event_edges table (multi-parent natural) - Q4: Typed if_flags/unless_flags/requires_event_id columns - Q5: Subsume deadline_concepts into event_types.concept_slug
75 KiB
Deadline Data Model — Proceedings-as-DAG
Author: einstein (consultant)
Date: 2026-05-08
Task: t-paliad-158 ([Consultant] Deadline data model — proceedings-as-DAG analysis + recommendation)
Branch: mai/einstein/consultant-deadline-data
Status: DESIGN — analysis only, no schema changes in this branch.
Predecessors read: docs/audit-fristenrechner-completeness-2026-04-30.md (curie), docs/plans/unified-fristenrechner.md + docs/plans/unified-fristenrechner-v3.md (cronus, archived author), docs/design-courts-per-country-holidays-2026-05-05.md (cronus, on-hold).
Companion: feynman is in flight on mai/feynman/fristenrechner (t-paliad-157). Read that branch's WIP if pushed; do not take dependencies on it. This analysis is upstream of any in-flight implementation.
0. Executive summary
The problem. paliad's deadline knowledge today is fragmented across five tables and two parallel calculators. The structural truth m wants — court system → proceeding → ordered event types → conditional trigger edges — is mostly implicit: it lives partly in deadline_rules.parent_id (one-parent tree per proceeding), partly in trigger_events+event_deadlines (flat YouPC import), partly in deadline_concepts (cross-proceeding semantic bridge), partly in event_categories (Pathway-B navigation taxonomy), and partly in free-text columns on paliad.projects. Conditions are encoded twice — once via condition_rule_id (FK to a sibling rule), once via condition_flag text[] (named flags). Multi-parent triggers cannot be expressed cleanly. The court-system axis is missing entirely.
What m wants (verbatim, 2026-05-08 16:01):
All I want is a natural sequence of proceedings which belong to a court system. And of course we can classify deadlines into concepts and make it easier for the AI to understand, but in its core I need event types that are related to proceedings and connected as a sequence, one triggering the other, with some conditions possibly changing the resulting sequence.
Locked m decisions (this doc, AskUserQuestion 2026-05-08 16:13–16:18):
| Q | Subject | Lock |
|---|---|---|
| Q1 | Court-system axis | Reuse courts.court_type as the system identity. Promote it to a paliad.court_types lookup. FK paliad.courts.court_type → court_types.code. Retire paliad.proceeding_types.jurisdiction. |
| Q2 | Proceeding instance | Project (or sub-project) IS the proceeding instance. Verbatim m: "Each UPC proceeding should be its own (sub-)project. And as such can be one proceeding or multiple if necessary. Flexibility is key." No new paliad.proceedings table. Multi-proceeding cases use sub-projects in the existing project tree. |
| Q3 | Edge model | First-class paliad.proceeding_event_edges table. Multi-parent triggers natural. parent_id on the legacy deadline_rules table retired. |
| Q4 | Conditions | Typed columns per edge: if_flags text[] (all must be set), unless_flags text[] (none may be set), requires_event_id uuid REFERENCES proceeding_event_types(id). SQL-queryable; no expression evaluator. |
| Q5 | Concept layer | Subsume deadline_concepts into proceeding_event_types.concept_slug column. Drop deadline_concepts table after backfill. Keep event_categories recursive tree as Pathway-B navigation overlay only — re-FK its junction onto concept_slug. |
Headline shape change. Today's two-rule-libraries-bridged-by-a-mat-view becomes one rule library: a graph of typed event-types connected by typed edges, scoped to proceedings, scoped to court systems. The instance side stays where it is (project tree). The AI/UX layers (concept tags, navigation tree) ride on top of the graph rather than parallel to it.
Migration shape. Additive build → atomic cutover per surface (Fristenrechner, deadline-search, /deadlines/new picker), all on the same boot. The 26 production paliad.deadlines rows survive untouched (their rule_code text already carries the citation; rule_id re-points to the new event-type/edge tuple post-cutover).
1. Map of current state
1.1 The five tables that carry deadline knowledge
┌──────────────────────────────┐
│ paliad.proceeding_types (26) │ jurisdiction text
│ ─ INF, REV, CCR, APM, … │ ('UPC'|'DE'|'EPA'|'DPMA')
│ ─ UPC_INF, UPC_REV, UPC_PI… │
│ ─ DE_INF, DE_NULL, DE_*_BGH │
│ ─ EPA_OPP, EPA_APP, EP_GRANT│
│ ─ DPMA_OPP, DPMA_*_BPATG… │
└──────────┬───────────────────┘
│ 1:N
▼
┌──────────────────────────────────────────────────────┐
│ paliad.deadline_rules (172) │
│ ─ uuid PK │
│ ─ proceeding_type_id int FK │
│ ─ parent_id uuid → self (one-parent tree) │
│ ─ code, name_de, name_en, description │
│ ─ primary_party (claimant|defendant|both|court) │
│ ─ event_type (filing|decision|order|hearing) │
│ ─ duration_value int, duration_unit text │
│ (months|weeks|days|working_days) │
│ ─ timing (after|before) │
│ ─ rule_code text, deadline_notes text+_en │
│ ─ legal_source text ← t-paliad-131 Phase A │
│ ─ concept_id uuid FK ← t-paliad-131 Phase A │
│ ─ condition_rule_id uuid ─┐ │
│ ─ condition_flag text[] ├ TWO mechanisms, │
│ ─ alt_duration_* / unit │ one structural idea │
│ ─ alt_rule_code │ │
│ ─ anchor_alt text ─┘ │
│ ─ is_spawn bool, spawn_label text │
│ ─ is_bilateral bool ← t-paliad-133 Phase A │
│ ─ sequence_order int │
└──────┬───────────────────────────────────────────────┘
│ concept_id (uuid)
▼
┌──────────────────────────────┐
│ paliad.deadline_concepts (57)│ the Unifier layer
│ ─ slug UNIQUE │ (t-paliad-131 Phase A)
│ ─ name_de, name_en │
│ ─ aliases text[] │
│ ─ party text │
│ ─ category (submission| │
│ decision|order|hearing) │
└──────┬───────────────────────┘
│ concept_id (uuid)
▼ (junction)
┌──────────────────────────────────────────┐
│ paliad.event_category_concepts (136) │ decision-tree leaf
│ ─ event_category_id FK │ → concept overlay
│ ─ concept_id FK │ (t-paliad-133)
│ ─ proceeding_type_code text -- narrow │
└──────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ paliad.event_categories (103)│ recursive tree (parent_id self-FK)
│ ─ slug, label_de, label_en │ Pathway-B navigation taxonomy
│ ─ step_question_de/_en │ (t-paliad-133, depth unlimited)
│ ─ icon, sort_order, is_leaf │
└──────────────────────────────┘
Parallel rule library — YouPC import (UPC-only, flat):
┌──────────────────────────────┐ ┌──────────────────────────────────┐
│ paliad.trigger_events (110) │ 1:N │ paliad.event_deadlines (77) │
│ ─ bigint PK (verbatim from │ ───────▶│ ─ bigint PK (verbatim ids) │
│ youpc.data.events) │ │ ─ trigger_event_id FK │
│ ─ code, name, name_de │ │ ─ duration_value, duration_unit │
│ ─ concept_id text │ │ (days|weeks|months| │
│ ↑ slug (text), NOT FK │ │ working_days) │
└──────────────────────────────┘ │ ─ alt_duration_* + combine_op │
│ (max|min — composite │
│ rule for R.198/R.213) │
│ ─ timing (before|after) │
│ ─ title, title_de, notes, _en │
└──────────┬───────────────────────┘
│ 1:N
▼
┌──────────────────────────────────┐
│ paliad.event_deadline_rule_codes │
│ (72 — one row per RoP citation) │
│ ─ event_deadline_id, rule_code, │
│ sort_order │
└──────────────────────────────────┘
The two rule libraries are bridged at search time only by paliad.deadline_search (mat-view, t-paliad-131 Phase C, migration 047): one row per (concept × context) where context is kind='rule' for deadline_rules rows or kind='trigger' for trigger_events rows. They share no FK.
Instance side (the per-case audit row):
┌─────────────────────────────────────────────────┐
│ paliad.deadlines (26 in production) │
│ ─ uuid PK │
│ ─ project_id uuid FK → paliad.projects(id) │
│ ─ rule_id uuid FK → deadline_rules(id) NULL │
│ ─ rule_code text -- citation, free-text │
│ (t-paliad-111 — survives rule rename) │
│ ─ title, description, due_date, original_due_ │
│ date, warning_date │
│ ─ status (pending|completed|cancelled|waived) │
│ ─ source (manual|imported|caldav|paliadin) │
│ ─ caldav_uid, caldav_etag │
│ ─ approval_status (approved|pending|legacy) │
│ + pending_request_id, approved_by/_at │
│ (t-paliad-138 dual-control, migration 054)│
│ ─ created_by, created_at, updated_at │
└─────────────────────────────────────────────────┘
│ deadline_id
▼
┌─────────────────────────────────────────────────┐
│ paliad.deadline_event_types (junction, 0..N) │
│ ─ deadline_id, event_type_id (composite PK) │
│ (t-paliad-088, migration 030) │
└─────────────────────────────────────────────────┘
▲
│ event_type_id
│
┌─────────────────────────────────────────────────┐
│ paliad.event_types (45) │
│ ─ uuid PK, slug, label_de, label_en │
│ ─ category (submission|decision|order|service| │
│ fee|hearing|other) │
│ ─ jurisdiction (UPC|EPO|DPMA|DE|any) NULL │
│ ─ trigger_event_id bigint NULL ─ loose linkage│
│ (NO FK constraint — youpc resync-safe) │
│ ─ created_by, is_firm_wide, archived_at │
└─────────────────────────────────────────────────┘
paliad.event_types is the user-facing classifier on per-case paliad.deadlines rows. It overlaps with paliad.trigger_events by ~70% (UPC submissions) and carries an optional trigger_event_id linkage column without an FK constraint by design (so a future YouPC re-sync can drop trigger ids without breaking event_types). It is distinct from paliad.event_categories (the Pathway-B decision tree) and distinct from paliad.deadline_rules.event_type (which is just a text column, values filing|decision|order|hearing).
So today the word "event type" identifies three different things in three different tables. Not necessarily wrong, but worth flagging.
1.2 Court / venue / jurisdiction
┌─────────────────────────────────────────────────────────────┐
│ paliad.courts (41 — t-paliad-122 migration 053) │
│ ─ id text PK (kebab, mirrors handlers/courts.go) │
│ ─ code, name_de, name_en │
│ ─ country text FK → paliad.countries(code) -- ISO-3166 │
│ ─ regime text NULL -- 'UPC'|'EPO'|NULL │
│ ─ court_type text -- 'UPC-LD'|'UPC-CD'|'UPC-CoA'| │
│ 'DE-LG'|'DE-OLG'|'DE-BGH'| │
│ 'DE-BPatG'|'DE-DPMA'|'EPA'|'NAT' │
│ ─ parent_id text FK → self │
│ ─ sort_order int, is_active bool │
└─────────────────────────────────────────────────────────────┘
The court_type column is currently free text (no constraint, no FK target). 41 rows are seeded across 11 distinct values. This is the column m's Q1 lock promotes to be the court-system identity.
paliad.holidays (55 rows) carries country ISO-3166 + regime ('UPC'|'EPO'|NULL). Federal DE public holidays = country='DE', regime=NULL; UPC summer/winter judicial vacations = country=NULL, regime='UPC'. The check constraint country IS NOT NULL OR regime IS NOT NULL enforces every row carries at least one.
1.3 Project side — what links a case to a proceeding today
┌─────────────────────────────────────────────────────────┐
│ paliad.projects (11 active in prod) │
│ ─ id uuid PK │
│ ─ type text -- 'mandat'|'litigation'|'patent'| │
│ 'verfahren'|'projekt' │
│ ─ parent_id uuid → self (project tree) │
│ ─ path text NOT NULL -- materialised ltree path │
│ (t-paliad-023, GiST-indexed, RLS-load-bearing) │
│ ─ title, reference, description, status │
│ ─ proceeding_type_id integer -- single FK │
│ → paliad.proceeding_types(id) │
│ ─ court text -- FREE TEXT, no FK to paliad.courts │
│ ─ country text │
│ ─ patent_number, filing_date, grant_date │
│ ─ case_number, billing_reference, client_number, │
│ matter_number, netdocuments_url │
│ ─ industry, ai_summary, metadata jsonb │
└─────────────────────────────────────────────────────────┘
So the project row carries:
- ONE proceeding-type FK (an integer, not nullable on
verfahrenprojects but nullable in the schema). - ONE court — but as free text, not FK'd to
paliad.courts.iddespite that table being seeded six days ago in migration 053. - NO trigger_date column. The trigger date is implicit in the
paliad.deadlines.original_due_dateof whichever Frist anchored the calc. - NO live-state column. There is no "currently at stage X" pointer.
There's no paliad.proceedings table. The conceptual link "this project IS a UPC infringement action" is the pair (project_id → proceeding_type_id), no further structure.
1.4 What lives where — by jurisdiction
| Jurisdiction | proceeding_types | deadline_rules | trigger_events | event_deadlines |
|---|---|---|---|---|
| UPC (legacy: INF/REV/CCR/APM/APP/AMD) | 6 | 36 | 0 | 0 |
| UPC (modern: UPC_INF/UPC_REV/UPC_PI/…) | 8 | 56 | 110 | 77 |
| DE (ZPO/PatG, LG/OLG/BGH/BPatG) | 5 | 40 | 0 | 0 |
| EPA (OPP/APP/EP_GRANT) | 3 | 23 | 0 | 0 |
| DPMA | 3 | 13 | 0 | 0 |
| Cross-cutting (Wiedereinsetzung, …) | 0 | 0 | 7 | 7 |
| Legacy ZPO_CIVIL placeholder | 1 | 4 | 0 | 0 |
| Total | 26 | 172 | 110 | 77 |
The two UPC generations (INF/REV/CCR/APM/APP/AMD from migration 008 vs UPC_INF/UPC_REV/UPC_PI/UPC_APP/UPC_DAMAGES/UPC_DISCOVERY/UPC_COST_APPEAL/UPC_APP_ORDERS from migration 012) coexist in production. Fristenrechner v3+ uses the modern set; the legacy six are unreferenced sediment kept "in case". This is technical debt orthogonal to the model question, flagged here for the migration plan in §4.
1.5 How conditional triggers are encoded today (concrete)
| Mechanism | Rules using it | Example |
|---|---|---|
condition_rule_id (FK to a sibling rule) |
2 | INF tree's inf.reply and inf.rejoin reference ccr.counterclaim — when CCR was filed in the same case, swap rule_code RoP.029.b → RoP.029.a (Reply) or duration 1mo → 2mo (Rejoinder). |
condition_flag text[] (named flags from request) |
17 | UPC_INF tree's with_ccr rules render only when the request includes with_ccr flag; UPC_REV's with_amend/with_cci parallel flags. |
alt_duration_value + alt_duration_unit + alt_rule_code |
4 | Swap-on-flag fallback (R.198/R.213 max-of-31d-or-20wd is encoded similarly on event_deadlines.alt_duration_* + combine_op). |
anchor_alt text (named alternate anchor) |
1 | EP_GRANT publish anchors on priority_date instead of parent rule's date. |
is_spawn + spawn_label (cross-tree edge) |
6 | INF tree's inf.appeal lives in APP tree but parent_id points into INF.decision — the rule itself sits in proceeding APP, the parent sits in proceeding INF. Implicit cross-proceeding edge. |
condition_flag AND alt_duration_value together |
3 | UPC_INF Replik has condition_flag=['with_ccr'] swapping duration via alt_duration_value rather than gating render. |
The two-mechanism split is what bites every contributor. condition_rule_id was the Phase-A approach; condition_flag was added by t-paliad-086 PR-3 because condition_rule_id couldn't model "user told me they ARE in CCR mode without there being a rule of mine to point at." Both still in production. New rules should use condition_flag; the 2 legacy condition_rule_id rules are equivalent to single-element flag arrays and were not migrated.
1.6 The two calculators
- Tree calculator —
internal/services/fristenrechner.go(803 lines): walksdeadline_rulesparent_id chain, anchors on input trigger_date, applies condition_flag gates, swapsalt_*columns when flags are set, classifies court-determined nodes (isCourtDeterminedRule:primary_party='court' OR event_type IN ('hearing','decision','order')) so they render as "no date — court will set it". Used by/tools/fristenrechnerfor the 16 modern proceeding-tree views. - Flat calculator —
internal/services/event_deadline_service.go(315 lines): single trigger_event ID + trigger_date → list of event_deadlines, no parent chain. Compositecombine_op='max'/'min'resolves R.198/R.213. Working-days math viaaddWorkingDaysoverpaliad.holidays. Used by Pathway-B "Was kommt nach…" tab.
The two share holidays.go for working-day skip logic. Otherwise the code paths are independent.
2. Gaps vs proceedings-as-DAG framing
m's framing decoded into structural facts the data model SHOULD support:
| m says | Data model needs |
|---|---|
| "court system" is the outer container | One row per court system the firm practises in (UPC-CFI, UPC-CoA, DE-LG-Patentkammer, DE-OLG, DE-BGH, DE-BPatG, EPO, DPMA, …). Procedural rules belong to a court system. |
| "a natural sequence of proceedings" | One row per named procedural shape (UPC infringement action, UPC revocation action, EPO opposition, DE LG patent action). A proceeding belongs to ONE court system. |
| "event types … related to proceedings" | Each event-type node belongs to a proceeding. Some nodes may be shared across proceedings (final-decision, oral-hearing). |
| "connected as a sequence, one triggering the other" | Edges between event-types within a proceeding. Multi-parent allowed (one node may be triggered by either of two predecessors). |
| "with some conditions possibly changing the resulting sequence" | Edges carry conditions. Conditions are first-class (queryable, AI-readable). |
| "classify deadlines into concepts and make it easier for the AI" | Concept tag layer on each event-type. Rides on top of the graph, doesn't compete with it. |
2.1 Concrete gaps
Gap G1 — Court system is not in the data model
Today: proceeding_types.jurisdiction text ('UPC'|'DE'|'EPA'|'DPMA') conflates court-system regime with national jurisdiction. The 41 paliad.courts rows carry court_type ('UPC-LD'|'UPC-CoA'|'DE-LG'|'DE-OLG'|'DE-BGH'|'DE-BPatG'|'EPA'|'DPMA'|'NAT'|…) as free text. There is no FK between the two.
Why it bites: "Show me every UPC procedural rule" requires proceeding_types.jurisdiction='UPC'. "Show me every rule that fires in a German LG patent chamber" requires reasoning about court_type='DE-LG' AND a proceeding that runs there — but the proceeding doesn't carry a court_type, the project's court does, and that's free text. The DE-LG and DE-OLG patent appeal proceedings (DE_INF, DE_INF_OLG) BOTH have jurisdiction='DE' on proceeding_types; nothing tells you DE_INF runs at LG and DE_INF_OLG runs at OLG except the proceeding name.
Concrete fail: today, the holiday lookup for "deadline computed for a UPC infringement action filed in München LD" needs UPC summer vacation + DE federal holidays. The intermediate join (project.court_type → applicable holiday set) is hardcoded in internal/services/holidays.go because there's no FK chain to walk.
Gap G2 — One project = one proceeding-type FK; multi-proceeding cases are forced into the project tree
Today: paliad.projects.proceeding_type_id integer is single-valued. A project that hosts BOTH a UPC infringement action and a separate revocation counterclaim must either:
(a) Tag itself with one of the two and lose half its proceeding context, or
(b) Be split into two child verfahren projects under a common litigation parent.
m's lock (Q2): Sub-projects are the right answer. "Each UPC proceeding should be its own (sub-)project." This is consistent with the project-tree model already in place since t-paliad-023 (data-model-v2). The fix isn't to add a paliad.proceedings table; it's to honour the existing tree by FK-tightening projects.proceeding_def_id on verfahren-typed projects.
Gap G3 — Edges are one-parent only; multi-parent triggers cannot be expressed cleanly
Today: Each deadline_rules row has at most one parent_id. A node like UPC inf.rejoin has TWO real-world predecessors:
- After Reply-to-SoD when no CCR was filed (1 month, RoP.029.c)
- After Reply-to-Defence-to-CCR when CCR was filed (1 month, RoP.029.e)
The current model collapses these into ONE rule with condition_flag=['with_ccr'] swapping alt_* columns, but that masks the true graph: there are two distinct edges into inf.rejoin, with different from_event_type and different rule_code. Today the calculator papers over this by anchoring inf.rejoin on whichever parent the parent_id points at and pretending the other parent doesn't exist for purposes of the chain walk.
Cross-proceeding edges (the legacy is_spawn flag, 6 rules) are an even uglier symptom — inf.appeal lives in proceeding APP but its parent_id points into INF. Two different proceedings, one edge. Today this is fine for tree traversal but breaks any "show me proceeding APP's structure" query because you have to know the edge crosses.
Gap G4 — Conditions encoded in two mechanisms
Today: 2 rules use condition_rule_id (FK to a sibling rule whose presence flips alt_duration / alt_rule_code), 17 rules use condition_flag text[] (named flags). Both still load-bearing in the calculator. Same idea, two columns.
Why it bites: Every new contributor has to learn both. The 2 legacy condition_rule_id rules are sentinel debt — they couldn't be deleted without rewriting the inf.reply / inf.rejoin classifier_flag dual-encoding (memory 652b856f t-paliad-086 PR-3 imported the flag-based variant alongside, did NOT migrate the legacy two).
Gap G5 — Two parallel rule libraries with no shared FK
Today:
deadline_rules(172 rows, UUID PK, parent-tree, condition_flag, alt_*) — the timeline calculator's source.trigger_events+event_deadlines(110+77 rows, bigint PK, flat trigger→deadline map, composite max/min) — the trigger calculator's source.
They are bridged at search time by paliad.deadline_search mat-view (concept slug as join key) but share no FK. A rule in deadline_rules and a deadline in event_deadlines can describe the same legal idea (e.g. UPC Klageerwiderung) and the only thing that ties them is whether someone happened to set the same concept_id/concept slug on both sides.
This costs us:
- Drift — when t-paliad-086 PR-3 fixed Tier-1 bugs in
deadline_rules, equivalent rows inevent_deadlineswere not touched. The two libraries can disagree on the same Frist. - Audit difficulty — "is this Frist correct?" requires reading both tables and the bridge.
- AI confusion — feeding the corpus to the LLM means feeding two different shapes of the same knowledge.
Gap G6 — Concept layer is a rope-bridge, not a column
Today: paliad.deadline_concepts (57 rows) is a separate table. deadline_rules.concept_id uuid FK. trigger_events.concept_id text (slug, NOT FK — string-walked). event_category_concepts.concept_id uuid FK (the navigation overlay). Three different referent types for the same entity.
Why it bites: Re-naming a concept (slug change) means walking three FK shapes. AI ingestion means joining four tables to get "what does this Frist mean." The cross-proceeding semantic identity (one Klageerwiderung in UPC ≅ one Klageerwiderung in DE_INF) is queryable but not load-bearing — the FK exists, but nothing constrains both rules to point at the same concept_id. Drift is silent.
Gap G7 — Conditional sequence changes are local to one edge
Today: A condition on rule X (e.g. condition_flag=['with_ccr']) gates whether rule X renders. It does NOT propagate. So if "with_ccr is true" should also mean "the Application-to-amend timeline becomes available in this proceeding," that's encoded as separate rules each with their own condition_flag=['with_ccr']. No "if condition C, the proceeding switches to track T" semantic.
Concrete example: UPC infringement with CCR has its OWN sub-proceeding shape (Defence-to-CCR with its own Reply/Rejoinder cycle, optional Application-to-amend). Today this is encoded as N additional rules in UPC_INF each gated on with_ccr. Tomorrow it could be one proceeding_event_edges row that says "if with_ccr then activate the CCR sub-graph rooted at this node."
This is not addressed by Q3+Q4 — multi-parent edges + typed conditions. We'll come closer, but a true track-switching semantic ("this proceeding has an alternate path that engages under condition X") is one level above the edge model and is deliberately deferred. See §6.4.
3. Target shape
This section translates m's locked decisions into a concrete schema and walks one full UPC infringement action to make the shape tangible.
3.1 Court system axis (Q1)
CREATE TABLE paliad.court_types (
code text PRIMARY KEY,
name_de text NOT NULL,
name_en text NOT NULL,
regime text -- 'UPC'|'EPO'|NULL (national)
CHECK (regime IS NULL OR regime IN ('UPC','EPO')),
sort_order int NOT NULL DEFAULT 0,
is_active bool NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO paliad.court_types (code, name_de, name_en, regime, sort_order) VALUES
-- UPC court systems
('UPC-LD', 'UPC-Lokalkammer', 'UPC Local Division', 'UPC', 10),
('UPC-CD', 'UPC-Zentralkammer', 'UPC Central Division', 'UPC', 20),
('UPC-CoA', 'UPC-Berufungsgericht', 'UPC Court of Appeal', 'UPC', 30),
('UPC-RD', 'UPC-Regionalkammer', 'UPC Regional Division', 'UPC', 40),
-- DE court systems
('DE-LG', 'Landgericht (Patentstreitkammer)',
'German Regional Court (patent chamber)', NULL, 50),
('DE-OLG', 'Oberlandesgericht (Patentsenat)',
'German Higher Regional Court (patent senate)', NULL, 60),
('DE-BGH', 'Bundesgerichtshof (X. Zivilsenat)',
'German Federal Court of Justice (Xth Civil Senate)', NULL, 70),
('DE-BPatG', 'Bundespatentgericht', 'German Federal Patent Court', NULL, 80),
('DE-DPMA', 'Deutsches Patent- und Markenamt',
'German Patent and Trade Mark Office', NULL, 90),
-- EPO
('EPA', 'Europäisches Patentamt', 'European Patent Office', 'EPO', 100),
-- National (non-UPC, non-DE-patent-track)
('NAT', 'Nationales Gericht', 'National Court', NULL, 200);
-- FK from existing courts table
ALTER TABLE paliad.courts
ADD CONSTRAINT courts_court_type_fk
FOREIGN KEY (court_type) REFERENCES paliad.court_types(code);
The 41 paliad.courts rows already carry the right court_type strings (verified live: 11 distinct values, all in the seed list above). The FK addition is a pure constraint upgrade, no data move.
3.2 Proceeding definitions (the named-sequence template)
-- Renamed + restructured from paliad.proceeding_types
CREATE TABLE paliad.proceeding_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE,
-- 'UPC_INF','UPC_REV','UPC_PI','UPC_APP','EPO_OPP',
-- 'EPO_APP','DE_INF_LG','DE_INF_OLG','DE_INF_BGH',
-- 'DE_NULL_BPATG','DE_NULL_BGH','DPMA_OPP','DPMA_APP','DPMA_RB'
name_de text NOT NULL,
name_en text NOT NULL,
description text,
court_type text NOT NULL
REFERENCES paliad.court_types(code), -- the system axis
category text NOT NULL -- 'litigation'|'opposition'|'examination'|'appeal'
CHECK (category IN ('litigation','opposition','examination',
'appeal','enforcement','provisional')),
default_color text NOT NULL DEFAULT '#3b82f6',
sort_order int NOT NULL DEFAULT 0,
is_active bool NOT NULL DEFAULT true,
is_fristenrechner bool NOT NULL DEFAULT true,
-- whether this proceeding is exposed in /tools/fristenrechner
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX proceeding_definitions_court_type_idx
ON paliad.proceeding_definitions(court_type);
CREATE INDEX proceeding_definitions_category_idx
ON paliad.proceeding_definitions(category);
Each row IS a "natural sequence of [a class of] proceedings." court_type is the outer container m asked for. The legacy proceeding_types.jurisdiction text column is dropped — its information is now derivable via court_types.regime.
3.3 Event types (the nodes)
CREATE TABLE paliad.proceeding_event_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
proceeding_def_id uuid NOT NULL
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
-- Each node belongs to one proceeding. Cross-proceeding shared
-- semantics are expressed via concept_slug (Q5 lock), not by
-- attaching one node to multiple proceedings.
code text NOT NULL,
-- Local code, unique within proceeding_def_id.
-- Examples: 'soc','sod','reply','rejoinder','decision'
name_de text NOT NULL,
name_en text NOT NULL,
description text,
party text NOT NULL
CHECK (party IN ('claimant','defendant','both','court','any')),
kind text NOT NULL
CHECK (kind IN ('filing','decision','order','hearing','service','fee')),
concept_slug text, -- Q5 lock — subsumes paliad.deadline_concepts
-- Free-form slug; matches old concept slugs verbatim post-migration.
-- One LLM-readable identifier shared across proceedings.
-- E.g. 'statement-of-defence' on both UPC_INF.sod and DE_INF_LG.klageerw.
concept_de text, -- denormalised from old deadline_concepts.name_de
concept_en text, -- denormalised from old deadline_concepts.name_en
aliases text[] NOT NULL DEFAULT '{}',
-- Search aliases inherited from old deadline_concepts.aliases.
-- Indexed via gin (aliases) for the search bar.
is_root bool NOT NULL DEFAULT false,
-- True for the trigger node of a proceeding (the Statement of Claim,
-- the Statement for Revocation, the EPO opposition filing). The
-- proceeding instance's trigger_date anchors here.
sort_order int NOT NULL DEFAULT 0,
is_active bool NOT NULL DEFAULT true,
is_bilateral bool NOT NULL DEFAULT false,
-- Carried over from t-paliad-133. When true AND party='both',
-- mirror into both columns of the columns-view.
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (proceeding_def_id, code)
);
CREATE INDEX proceeding_event_types_def_idx ON paliad.proceeding_event_types(proceeding_def_id);
CREATE INDEX proceeding_event_types_concept_idx ON paliad.proceeding_event_types(concept_slug)
WHERE concept_slug IS NOT NULL;
CREATE INDEX proceeding_event_types_aliases_idx ON paliad.proceeding_event_types USING gin (aliases);
CREATE INDEX proceeding_event_types_de_trgm ON paliad.proceeding_event_types USING gin (name_de gin_trgm_ops);
CREATE INDEX proceeding_event_types_en_trgm ON paliad.proceeding_event_types USING gin (name_en gin_trgm_ops);
Per Q5: concept_slug + concept_de + concept_en + aliases are columns on the node, not a separate table. The 57 paliad.deadline_concepts rows distill into ~57 distinct concept_slug values across the ~172+ migrated nodes. Cross-proceeding "all rules with concept_slug='statement-of-defence'" is a single-column index lookup, not a join.
3.4 Edges (the typed triggers)
CREATE TABLE paliad.proceeding_event_edges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
proceeding_def_id uuid NOT NULL
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
from_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
-- NULL = root edge (anchors on the proceeding instance's trigger_date).
-- The to_event must have is_root=true for null-from edges.
to_event_id uuid NOT NULL
REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
duration_value int NOT NULL DEFAULT 0,
duration_unit text NOT NULL DEFAULT 'months'
CHECK (duration_unit IN ('days','weeks','months','working_days')),
timing text NOT NULL DEFAULT 'after'
CHECK (timing IN ('after','before')),
-- 'before' supports countdown deadlines (e.g. "1 month before oral hearing").
combine_op text CHECK (combine_op IS NULL OR combine_op IN ('max','min')),
alt_duration_value int,
alt_duration_unit text CHECK (alt_duration_unit IS NULL
OR alt_duration_unit IN ('days','weeks','months','working_days')),
-- combine_op + alt_* implements composite rules
-- (e.g. R.198/R.213 max(31d, 20wd)). Only set on edges
-- where the rule itself is composite — flag-conditioned
-- variants use sibling edges, not alt_*.
-- ===== Q4 lock — typed conditions =====
if_flags text[] NOT NULL DEFAULT '{}',
-- All flags in this array must be set for the edge to fire.
-- Empty array = unconditional.
unless_flags text[] NOT NULL DEFAULT '{}',
-- None of these flags may be set for the edge to fire.
requires_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE SET NULL,
-- Edge fires only if this OTHER event was actually filed/recorded
-- in the proceeding instance (replaces today's condition_rule_id).
-- NULL = no occurrence prerequisite.
-- ===== Citation =====
rule_code text, -- 'RoP.029.b','PatG §111(1)','§ 276 ZPO'
legal_source text, -- 'UPC.RoP.029.b' / 'DE.PatG.111.1' / 'EU.EPÜ.108'
is_mandatory bool NOT NULL DEFAULT true,
deadline_notes_de text,
deadline_notes_en text,
sort_order int NOT NULL DEFAULT 0,
is_active bool NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Sanity: from_event must belong to the same proceeding_def
-- (cross-proceeding edges are out-of-scope per §3.6 — modelled
-- via separate root edges in each proceeding instead).
CONSTRAINT edge_from_in_def CHECK (
from_event_id IS NULL OR proceeding_def_id IS NOT NULL
)
);
CREATE INDEX edges_def_idx ON paliad.proceeding_event_edges(proceeding_def_id);
CREATE INDEX edges_to_idx ON paliad.proceeding_event_edges(to_event_id);
CREATE INDEX edges_from_idx ON paliad.proceeding_event_edges(from_event_id)
WHERE from_event_id IS NOT NULL;
CREATE INDEX edges_requires_idx ON paliad.proceeding_event_edges(requires_event_id)
WHERE requires_event_id IS NOT NULL;
CREATE INDEX edges_if_flags_idx ON paliad.proceeding_event_edges USING gin (if_flags);
CREATE INDEX edges_unless_flags_idx ON paliad.proceeding_event_edges USING gin (unless_flags);
CREATE INDEX edges_rule_code_idx ON paliad.proceeding_event_edges(rule_code)
WHERE rule_code IS NOT NULL;
Multi-parent semantics: when two edges share the same to_event_id, both compute candidate dates; the calculator picks per the edges' if_flags/unless_flags/requires_event_id predicates. If multiple edges remain feasible for the same target, the rendered Frist is the LATEST of the candidates (paying lip service to the most-conservative-first principle); a future edge-priority column can refine this if needed.
Composite within an edge (combine_op): used only when the rule itself is structurally composite (R.198 / R.213 max-of-two-units). Flag-driven variants (with_ccr swaps duration 1mo→2mo) become two sibling edges with disjoint if_flags predicates — the cleaner expression of the same idea.
3.5 Project ↔ proceeding linkage (Q2)
-- Per Q2 lock — project (or sub-project) IS the proceeding instance.
ALTER TABLE paliad.projects
ADD COLUMN proceeding_def_id uuid
REFERENCES paliad.proceeding_definitions(id),
ADD COLUMN court_id text
REFERENCES paliad.courts(id),
ADD COLUMN proceeding_trigger_date date,
-- The date that anchors the root edge of this proceeding.
-- Null until the trigger-event has actually occurred.
ADD COLUMN proceeding_status text NOT NULL DEFAULT 'pending'
CHECK (proceeding_status IN ('pending','active','suspended','concluded','withdrawn'));
-- Backfill from the existing integer FK + free-text court column.
UPDATE paliad.projects p
SET proceeding_def_id = pd.id
FROM paliad.proceeding_definitions pd
JOIN paliad.proceeding_types pt ON pt.code = pd.code
WHERE p.proceeding_type_id = pt.id;
-- Free-text court → FK by best-effort string match.
UPDATE paliad.projects p
SET court_id = c.id
FROM paliad.courts c
WHERE p.court IS NOT NULL
AND lower(p.court) IN (lower(c.id), lower(c.code), lower(c.name_de), lower(c.name_en));
-- After backfill (separate migration, gated on QA):
-- ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
-- ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
-- ALTER TABLE paliad.projects DROP COLUMN country; -- inferred via court → court_type → country
A verfahren-typed project carries proceeding_def_id (the template) + court_id (the venue) + proceeding_trigger_date (the anchor for downstream edges). A mandat/litigation-typed project does NOT carry these (NULL is fine). Multi-proceeding cases live as sibling verfahren projects under a shared parent — exactly m's lock.
The proceeding_status column gives the per-instance live state m wanted (pending → active → concluded) without a separate paliad.proceedings table. Future fields (current-stage event_type_id, last_advanced_at, expected-decision-date) extend this column set without disturbing other layers.
3.6 Cross-proceeding edges — explicit retirement
The current is_spawn flag (6 rules) encodes "filing of A in proceeding X opens proceeding Y" by parking a rule in proceeding Y's tree with parent_id pointing into proceeding X. Concretely: inf.appeal lives in APP but its parent is INF.decision.
In the new shape: each proceeding's graph is closed. Cross-proceeding triggers are modelled at the instance layer — when the user records "decision in INF reached on date D," they instantiate a NEW verfahren sub-project (proceeding APP) with proceeding_trigger_date=D. The graph stays clean; the cross-proceeding step is a project-tree action, not an edge.
This is a small UX shift (today the appeal Frist auto-renders inside the INF timeline; tomorrow the user explicitly spawns the appeal sub-project to see its Fristen) but the alternative — letting proceeding_event_edges straddle proceedings — pollutes the model. Defer cross-proceeding-edge support; add a sub-project-creation shortcut on the decision-event UI instead.
3.7 Concept layer — what stays, what goes
Drops:
paliad.deadline_concepts(57 rows). Content lifts toproceeding_event_types.concept_slug+concept_de+concept_en+aliases.paliad.deadline_rules.concept_idFK. Replaced byproceeding_event_types.concept_slugtext column.paliad.trigger_events.concept_id text(already a slug, was never an FK). Migrated to the matchingproceeding_event_typesrows — see §4.
Stays:
paliad.event_categories(103 rows) — Pathway-B navigation taxonomy. Recursive tree, decision-tree UI. Re-FK its junction ontoconcept_slug:
ALTER TABLE paliad.event_category_concepts
DROP CONSTRAINT event_category_concepts_concept_id_fkey;
ALTER TABLE paliad.event_category_concepts
ADD COLUMN concept_slug text;
UPDATE paliad.event_category_concepts ecc
SET concept_slug = dc.slug
FROM paliad.deadline_concepts dc
WHERE ecc.concept_id = dc.id;
ALTER TABLE paliad.event_category_concepts
ALTER COLUMN concept_slug SET NOT NULL,
DROP COLUMN concept_id;
The category tree is now a thin overlay that maps "user clicked 'Hinweisbeschluss'" to the set of concept_slugs whose nodes should appear as cards. No separate concept identity required — the slug is the bridge.
Stays unchanged:
paliad.event_types(45 rows) — the instance-side user-facing classifier onpaliad.deadlines. Per t-paliad-088 this is firm-wide-or-private, archive-only, with optional loose-linkagetrigger_event_id. Untouched by this design — it's a different layer (instance tag, not template node). After migration, the loose linkage column can be repurposed:event_types.proceeding_event_type_id uuid(still loose, still nullable) — maintained as a follow-up, not in scope for the cutover.
3.8 Worked example — UPC infringement action (with CCR variant)
The mermaid below is one full proceeding's graph in the new shape. Solid edges fire unconditionally; dashed edges fire only when the labelled flag is set. Multi-parent at inf.rejoinder is the headline shape change.
flowchart TD
inf_soc["📄 inf.soc<br/>Statement of Claim<br/>concept-slug: statement-of-claim<br/>kind: filing • party: claimant • is_root: true"]:::root
inf_prelim["⚠️ inf.prelim<br/>Preliminary Objection<br/>concept-slug: preliminary-objection<br/>RoP.019.1"]
inf_sod["📄 inf.sod<br/>Statement of Defence<br/>concept-slug: statement-of-defence<br/>RoP.023"]
inf_ccr["⚖️ inf.ccr_counterclaim<br/>Counterclaim for Revocation<br/>concept-slug: counterclaim-for-revocation<br/>RoP.025 • is_bilateral"]
inf_amend["📐 inf.app_to_amend<br/>Application to amend patent<br/>concept-slug: application-to-amend-patent<br/>RoP.030"]
inf_reply_no["📝 inf.reply<br/>Reply to Defence (no CCR)<br/>concept-slug: reply-to-defence<br/>RoP.029.b"]
inf_reply_w["📝 inf.reply_with_ccr<br/>Defence-to-CCR + Reply<br/>concept-slug: reply-to-defence<br/>RoP.029.a"]
inf_def_amend["📝 inf.defence_to_amend<br/>Defence to App-to-amend<br/>concept-slug: defence-to-amend-patent<br/>RoP.032.1"]
inf_rejoin["📝 inf.rejoinder<br/>Rejoinder<br/>concept-slug: rejoinder-to-reply<br/>RoP.029.c|RoP.029.d"]
inf_interim["🧑⚖️ inf.interim<br/>Interim Conference<br/>kind: hearing • party: court"]
inf_oral["⚖️ inf.oral<br/>Oral Hearing<br/>kind: hearing • party: court"]
inf_decision["🏛️ inf.decision<br/>Decision on the merits<br/>concept-slug: decision-on-merits<br/>kind: decision • party: court"]
inf_costs["💰 inf.cost_application<br/>Application for cost decision<br/>concept-slug: application-for-cost-decision<br/>RoP.151 • 1mo from decision"]
inf_soc -- "1mo" --> inf_prelim
inf_soc -- "3mo (RoP.023)" --> inf_sod
inf_soc -- "3mo (RoP.025)<br/>if_flags: with_ccr" -.-> inf_ccr
inf_soc -- "3mo (RoP.030)<br/>if_flags: with_amend" -.-> inf_amend
inf_sod -- "2mo (RoP.029.b)<br/>unless_flags: with_ccr" --> inf_reply_no
inf_ccr -- "2mo (RoP.029.a)" --> inf_reply_w
inf_amend -- "2mo (RoP.032.1)<br/>requires_event: inf.app_to_amend" -.-> inf_def_amend
inf_reply_no -- "1mo (RoP.029.c)<br/>unless_flags: with_ccr" --> inf_rejoin
inf_reply_w -- "1mo (RoP.029.d)<br/>if_flags: with_ccr" --> inf_rejoin
inf_rejoin -.-> inf_interim
inf_interim --> inf_oral
inf_oral --> inf_decision
inf_decision -- "1mo (RoP.151)" --> inf_costs
classDef root fill:#c6f41c,stroke:#000,stroke-width:2px,color:#000
Anatomy of the multi-parent into inf.rejoinder:
-- Edge from no-CCR Reply → Rejoinder (1 month, RoP.029.c)
INSERT INTO paliad.proceeding_event_edges
(proceeding_def_id, from_event_id, to_event_id,
duration_value, duration_unit, rule_code, legal_source,
unless_flags)
VALUES
(:upc_inf, :inf_reply_no, :inf_rejoin,
1, 'months', 'RoP.029.c', 'UPC.RoP.029.c',
ARRAY['with_ccr']);
-- Edge from CCR-track Reply → Rejoinder (1 month, RoP.029.d)
INSERT INTO paliad.proceeding_event_edges
(proceeding_def_id, from_event_id, to_event_id,
duration_value, duration_unit, rule_code, legal_source,
if_flags)
VALUES
(:upc_inf, :inf_reply_w, :inf_rejoin,
1, 'months', 'RoP.029.d', 'UPC.RoP.029.d',
ARRAY['with_ccr']);
The current encoding (one rule with condition_flag=['with_ccr'] swapping alt_duration_value=2) is rewritten as two structurally-clean sibling edges. The calculator's logic simplifies: pick the edge whose if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events). No special-cased alt_* swap path.
3.9 Five more proceedings spec'd at the DAG-shape level
For each, the node count is shown along with the distinguishing edge feature that the new model handles cleanly. Full graphs are out of scope for the design doc — the coder shift will port migrations 008/009/012/041–046 row-by-row.
| Proceeding | Court system | Nodes | Distinguishing edge feature |
|---|---|---|---|
| UPC infringement action (UPC_INF, §3.8) | UPC-LD / UPC-CD | ~15 | Multi-parent into inf.rejoinder; if_flags/unless_flags carve the with-CCR / no-CCR tracks; requires_event_id gates inf.defence_to_amend on actual filing of inf.app_to_amend. |
| UPC standalone revocation (UPC_REV) | UPC-CD | ~15 | TWO independent flags (with_amend, with_cci) gate the App-to-amend cycle and the Counterclaim-for-Infringement sub-track respectively. Each flag ⇒ ~4 sibling edges activate. Today this is encoded as 8 rules each tagged with one or both flags; tomorrow as edges into a clearly-labelled second-track sub-graph. |
| EPO opposition (EPO_OPP) | EPA | ~8 | Root edge from the "Decision to grant EP" external trigger anchors epo_opp.notice (9-month opposition period, Art.99 EPC). Subsequent edges (R.79, R.116) are unconditional. Rule data flat — no flag conditions. |
| DE LG patent action (DE_INF_LG) | DE-LG | ~9 | Root edge anchors on klage.einreichung. The two-step Verteidigungsanzeige (§276.1, 2 weeks) followed by Klageerwiderung (§276.1.S2, court-set, ≥2 weeks) is two sequential edges, no flag. The § 276 deadline regime maps cleanly to requires_event_id if a future feature wants to gate Klageerwiderung on whether Verteidigungsanzeige was timely filed. |
| DE LG → OLG appeal (DE_INF_OLG) | DE-OLG | ~7 | Synthetic root node olg.zustellung_urteil (party='both', is_root=true) anchors on the LG decision date — bridging the cross-proceeding decision-to-appeal link as a project-tree spawn (§3.6). Berufung 1mo (§517 ZPO), Berufungsbegründung 2mo from filing-of-Berufung (§520.2) — multi-parent edge candidate if the user's date overrides. |
| DPMA → BPatG Beschwerde (DPMA_BPATG_BESCHWERDE) | DE-BPatG | ~5 | Two sibling edges from dpma.beschluss to bpatg.beschwerde: 1mo standard (§73 PatG), 2mo if if_flags=['ausland'] (foreign-resident extension). The flag-conditioned variant is 100% naturally an edge condition, no alt_* plumbing needed. |
| EPA Beschwerde (Boards of Appeal) (EPO_APP) | EPA | ~6 | Root node epo.entsch anchors a 2-month notice + 4-month grounds chain (Art.108 EPC). The R.106 RPBA Petition for Review fires as a sibling edge with if_flags=['fundamental_defect'] — clean. |
The edge model collapses all the today's flag/swap encodings into "edges with predicates," which is genuinely simpler to reason about and AI-friendly (each edge is a self-contained legal fact: from-X-to-Y-in-D-units-iff-conditions).
4. Migration path
4.1 Strategy: additive build → cutover per surface, one boot
NOT a graph-on-top. The Q3+Q5 locks (separate edges table, drop concept table) are structural — keeping deadline_rules AND proceeding_event_types AND proceeding_event_edges AND deadline_concepts simultaneously is the worst of both worlds (more layers, no clarity). The migration is genuinely additive build → cutover.
NOT a destructive cutover in one big migration. The 26 production deadlines, the running Fristenrechner, the deadline-search mat-view, and the currently-shipping t-paliad-138 approval flow are all live. We need every one of them to work mid-migration.
The right shape: four migrations, four boots, one feature cutover per boot. The prior table stays till the end, then drops.
4.2 Phase M1 — additive build (one boot, zero behaviour change)
Single migration. Creates new tables, populates from old, leaves old in place. Fristenrechner + deadline-search keep using the old tables; paliad.deadlines keeps rule_id pointing to deadline_rules. Day-1 deploy = no user-visible change.
1. CREATE paliad.court_types + seed 11 rows + FK from paliad.courts.court_type.
2. CREATE paliad.proceeding_definitions; backfill from paliad.proceeding_types
(rows that survive — drop the obsolete legacy 6 INF/REV/CCR/APM/APP/AMD,
keep only the 16 active fristenrechner sets + ZPO_CIVIL).
3. CREATE paliad.proceeding_event_types; backfill from deadline_rules
(one row per surviving rule), with concept_slug + concept_de + concept_en
+ aliases denormalised from deadline_concepts via the concept_id FK.
4. CREATE paliad.proceeding_event_edges; backfill:
- parent_id ⇒ from_event_id (or NULL when parent_id IS NULL).
- condition_flag ⇒ if_flags ([] when NULL).
- condition_rule_id ⇒ requires_event_id (the 2 legacy rules).
- alt_duration_value/_unit/_rule_code present:
emit a SIBLING edge (the alt path) instead of an alt_* column on
the same edge. The 4 rules with alt_* split into 8 rows.
- is_spawn=true rules ⇒ DO NOT migrate the cross-proceeding parent_id;
leave as orphaned root edges in the destination proceeding_def
(these are the §3.6 retirement candidates; flag them for the
project-tree-spawn UX in Phase M3).
5. ALTER paliad.projects ADD proceeding_def_id, court_id,
proceeding_trigger_date, proceeding_status. Backfill via the existing
proceeding_type_id integer + courts string-match heuristic (§3.5).
6. KEEP everything else: deadline_rules, deadline_concepts, trigger_events,
event_deadlines, event_categories — all stay, all readable.
Test gate: server boots, /tools/fristenrechner works (still on old tables), /deadlines/new works, /api/projects/{id} carries the new project columns (NULL on legacy rows is OK), no user-visible change. Run smoke 6/6 (per t-paliad-088 pattern, see memory 35a08abd).
4.3 Phase M2 — calculator cutover (one boot, behaviour swap)
Switch internal/services/fristenrechner.go from deadline_rules to proceeding_event_types + proceeding_event_edges. The walk algorithm changes:
| Today | Tomorrow |
|---|---|
| Walk parent_id chain from a root rule, anchor on triggerDate at root, descend, apply condition_flag gates and alt_* swaps. | BFS from root edge (from_event_id IS NULL) of the proceeding, anchor on triggerDate, for each node enumerate inbound edges, filter by predicates (if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events)), pick the edge that fires (LATEST candidate when multiple), compute due_date, recurse. |
isCourtDeterminedRule(r) discriminator. |
Same predicate, lifted to the node (kind IN ('hearing','decision','order') OR party='court'). |
Composite max/min via event_deadline.combine_op. |
Same column on the edge. |
anchor_alt='priority_date' on EP_GRANT publish. |
Folded into a per-proceeding-def "anchor_options" enum — Phase M3 problem, NOT M2. EP_GRANT publish stays specially-handled in Go for one boot. |
Switch the trigger calculator (event_deadline_service.go) at the same time. The trigger_events (110) + event_deadlines (77) data folds into the new shape:
- Each
trigger_eventbecomes a node (concept_slug from the existing slug column). - Each
event_deadlinebecomes a node + an edge from the trigger node to it. event_deadline_rule_codes(72 RoP citations, multiple per deadline) — the new shape only carries ONErule_codeper edge. Per row, picksort_order=0as the canonical citation; remaining 0-2 codes per edge become a separatepaliad.proceeding_event_edge_alt_codes(loose-linkage table) — out of scope for this design but flagged.
Search service (internal/services/deadline_search_service.go): rebuild paliad.deadline_search mat-view to read from the new tables. The kind discriminator ('rule'|'trigger') collapses — every row is a (node, edge_in) pair now. UI ranks unchanged.
Test gate: Full Playwright smoke walk through the 16 modern proceedings + the trigger-search Pathway-B flow + the with-CCR flag toggle. Recompute spot-check vs t-paliad-086/111 golden results (Klageerwiderung 2026-04-30 → 2026-08-31, etc). If a Frist drifts more than ±1 day across the migration boundary, BLOCK.
4.4 Phase M3 — instance-side cutover (one boot)
paliad.deadlines.rule_id re-points: today it FKs deadline_rules.id; tomorrow it should FK to a tuple (event_type_id, edge_id) — but we can't easily express a 2-column FK. Two options:
- Option A (chosen):
deadlines.rule_idretired entirely. The legal citation already lives indeadlines.rule_code text(per t-paliad-111). The structural pointer becomesdeadlines.event_type_id uuid REFERENCES proceeding_event_types(id)— node-level, since the edge is an implementation detail. The set of edges that led to this Frist is recoverable on read by walking edges-into-this-event-type-of-the-proceeding-instance. - Option B (rejected): Keep both rule_id (NULL during transition) AND event_type_id. Adds a deprecation column for unclear value. Skip.
ALTER TABLE paliad.deadlines
ADD COLUMN event_type_id uuid REFERENCES paliad.proceeding_event_types(id);
UPDATE paliad.deadlines d
SET event_type_id = pet.id
FROM paliad.proceeding_event_types pet
WHERE pet.code IN (SELECT dr.code FROM paliad.deadline_rules dr WHERE dr.id = d.rule_id)
AND pet.proceeding_def_id = (
SELECT pd.id FROM paliad.proceeding_definitions pd
JOIN paliad.proceeding_types pt ON pt.code = pd.code
WHERE pt.id = (SELECT dr.proceeding_type_id FROM paliad.deadline_rules dr
WHERE dr.id = d.rule_id)
);
ALTER TABLE paliad.deadlines DROP COLUMN rule_id;
-- Keep deadlines.rule_code text — it's user-visible and stable.
The 26 production deadlines need spot-check; a stale rule_code value (e.g. 'RoP.023') survives untouched, and the new event_type_id re-anchors the structural reference.
Project-tree spawn UX (deferred from §3.6's cross-proceeding-edge retirement): the old is_spawn-flagged rules in INF/REV/CCR (e.g. inf.appeal) had a one-click "create the next proceeding" affordance via the appeal Frist's spawning. Replace with: at inf.decision event-type detail page, show "Spawn Berufung sub-project" button → creates a new verfahren project under the same parent with proceeding_def_id=DE_INF_OLG and proceeding_trigger_date defaulting to the decision date. The graph stays clean; the spawn happens at the project tree, with one explicit click.
4.5 Phase M4 — drop legacy (one boot, no behaviour change)
DROP MATERIALIZED VIEW paliad.deadline_search; -- recreated in M2 against new tables
DROP TABLE paliad.event_deadline_rule_codes;
DROP TABLE paliad.event_deadlines;
DROP TABLE paliad.trigger_events;
DROP TABLE paliad.deadline_rules; -- 172 rows gone
DROP TABLE paliad.deadline_concepts; -- 57 rows gone
DROP TABLE paliad.proceeding_types; -- 26 rows gone
ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
-- Keep projects.country (used by holiday lookup as a fallback).
After Phase M4 the schema is the locked target. Total elapsed: 4 migrations, 4 boots. Each boot is reversible up to the M4 drop (which IS destructive).
4.6 What about feynman's in-flight branch?
feynman is currently writing migrations on mai/feynman/fristenrechner for t-paliad-157. This consultant analysis is upstream of his implementation and does NOT change feynman's brief — he ships what's specified there. After feynman lands, this design's M1 migration starts on top of his work; the proceeding_event_types backfill SELECTs from whatever shape deadline_rules is in at that point. No coordination required beyond "M1 picks up from feynman's HEAD."
Branch hygiene: nothing committed in mai/einstein/consultant-deadline-data touches code. Only docs/design-deadline-data-model-2026-05-08.md (this file). Merge to main at any time without conflict potential against feynman's branch.
5. AI-friendliness layer
5.1 What's load-bearing for the AI vs decoration
Load-bearing:
paliad.proceeding_event_types.concept_slug(e.g.'statement-of-defence') — the LLM's cross-proceeding identity. "What's the equivalent of a Klageerwiderung in EPO opposition?" → search proceedings for nodes withconcept_slug='statement-of-defence'or matching aliases.paliad.proceeding_event_types.aliases text[]— the search vocabulary. Lifts directly from olddeadline_concepts.aliases. Must remain curated; no user-edit in v1.paliad.proceeding_event_types.name_de+name_en— primary surface labels.paliad.proceeding_event_edges.rule_code+legal_source— citation grounding.paliad.proceeding_event_edges.if_flags/unless_flags/requires_event_id— the AI can reason about "this edge fires only if user has flagged with_ccr" without needing to evaluate a JSON expression.
Decoration (riding on top):
paliad.event_categories(103 nodes) — the navigation tree. The LLM doesn't need this for legal reasoning; it's a UX scaffold for users who don't know the legal vocabulary. Stays intact, re-FK'd to concept_slug.paliad.event_types(45 rows) — the user-facing instance-side classifier. Operationally useful (filter /deadlines by Type) but not load-bearing for the rule library. Stays unchanged.
5.2 The AI prompt lifecycle
- Search: "what is Klageerwiderung in UPC?" → trigram match on name_de + aliases column → returns
proceeding_event_typesrows where slug='statement-of-defence'. Result card lists: court_type pills (UPC-LD, UPC-CD, DE-LG, EPA), per-context durations + rule_codes via the inbound edges. - Calculate: user picks UPC-LD München LD + filing date + flags=['with_ccr']. Service walks the proceeding's edges, filters by predicates, returns a date timeline. AI doesn't need to be in this loop; it's deterministic graph walking.
- Reason: Paliadin (the LLM-backed assistant) gets fed
proceeding_event_types+proceeding_event_edgesfor the active proceeding when asked "explain my deadlines" — one self-contained subgraph, ~15-20 nodes, ~20-30 edges per proceeding. Fits comfortably in context. - Classify: when the user types "we got hit with a Hinweisbeschluss yesterday" — Paliadin matches against
aliases + name_deofproceeding_event_types, returns the matched concept_slug (hinweisbeschluss-stellungnahme), and uses the project'sproceeding_def_idto find the right node in the right proceeding's graph.
5.3 Where the concept layer's death helps the AI
Today the LLM has to reason about deadline_rules.concept_id → deadline_concepts.slug AND trigger_events.concept_id (text slug) AND event_categories → event_category_concepts.concept_id. Three different shapes for one identity.
Tomorrow there's ONE concept_slug text column on the proceeding-event-type node and ONE FK in the navigation junction. Same string, same column name, two query paths. Strictly easier for the LLM (and for human contributors).
5.4 Where the concept layer's death costs the AI
The 57 deadline_concepts rows had richer metadata than what survives as columns on the node:
aliases text[]— survives.description text— needs to merge into per-nodedescription text(already exists, just needs population).category(submission/decision/order/hearing) — survives (kindcolumn on node).party— survives (partycolumn on node, dominant case).sort_order— survives.
Net data loss: zero. Net query simplification: substantial.
6. Tradeoffs
6.1 What the migration costs
- Engineering effort. ~2 weeks of coder time across the 4 phases. M1 is a long-evening migration. M2 is the heavy lift (calculator rewrites, ~800 lines in fristenrechner.go + ~300 lines in event_deadline_service.go). M3 is shorter but coordinates with t-paliad-138 approval flow + CalDAV sync (the rule_id drop touches every code path that currently joins to deadline_rules —
internal/services/deadline_service.go,internal/services/event_service.go,internal/services/agenda_service.go,internal/handlers/deadlines.go, plus the frontend). - Migration complexity. 4 boots, each with a smoke gate. The M2 boot is the riskiest — calculator semantics are user-visible and date-precision-sensitive. Need a pre-cutover golden-set test (run BOTH calculators across the 16 active proceedings + 30+ trigger events for a representative trigger date, diff the outputs, fail if any non-trivial drift). t-paliad-086 PR-3 found a 60-day cap bug only because of the SoD-on-2026-04-30-lands-on-Saturday smoke; we'd need similar care here.
- Fristenrechner UX disruption. None expected. The Pathway-B navigation, the Verfahrensablauf timeline view, the search bar — all read paths can be preserved exactly because the underlying data shape is the same legal facts in different storage. The only user-visible change is at the spawning moment (§4.4 Phase M3 project-tree spawn instead of in-line appeal Frist).
- Documentation churn. docs/audit-fristenrechner-completeness-2026-04-30.md, docs/plans/unified-fristenrechner.md (cronus), docs/plans/unified-fristenrechner-v3.md (cronus) — all reference the old table names. These are historical (cronus retired from paliad per memory
cc28a8ad) so they don't need active maintenance, but new contributors will read them and get confused. Add a header to each pointing to this design doc as the structural-truth update.
6.2 What the migration buys
- One rule library, not two. No more deadline_rules + trigger_events drift. No more "did t-paliad-086 fix this in both?" The federated mat-view goes away. Search, calc, and AI all read the same shape.
- Multi-parent edges become natural. The CCR cross-flow that took t-paliad-131 Phase B1 a full PR + 7 new rules + condition_flag wiring becomes 7 sibling edges with disjoint
if_flags. Same semantics, half the schema awareness needed. - Court system axis is queryable.
SELECT * FROM proceeding_event_edges e JOIN proceeding_event_types et ON et.id = e.to_event_id JOIN proceeding_definitions pd ON pd.id = et.proceeding_def_id WHERE pd.court_type='UPC-LD' AND e.if_flags @> ARRAY['with_ccr']answers a real question that today requires walking three tables and string-matching. - The graph fits in an LLM prompt. ~15 nodes + 25 edges per proceeding, with concept tags + rule codes + party + condition flags inline. No federation, no slug-walking. Paliadin gets a tighter context.
- Conditions are typed, not stringly.
if_flags text[]+unless_flags text[]+requires_event_id uuid— the schema documents itself. Today'scondition_rule_id+condition_flagmix is two pages of code-comment to explain. - Court_type FK eliminates the holiday-lookup hardcoding. holidays.go's per-court mapping becomes a JOIN:
courts.country+court_types.regimedirectly produce the holiday set. - Extensibility for future condition kinds without further migrations. New typed columns can be added incrementally (e.g.
min_business_days intfor "Notfrist-only" rules); the JSON-DSL alternative would have meant version-bumping the expression evaluator each time.
6.3 Genuine cost: the column-based condition model breaks down at OR-of-3
Per Q4 the cost flagged on the option preview: a flag combination like "fires if (with_ccr AND with_amend) OR (without_ccr AND with_cci)" needs TWO sibling edges (one per branch). For OR-of-3-or-more disjoint branches the table has N edges fanning into the same target. This is OK at today's scale (the most complex rule has 2 flags) but if procedural complexity escalates we'd want to revisit. The natural escape valve is to add a JSON condition column alongside the typed columns later, evaluated only when present — but that's a future decision, not today's.
6.4 What we deliberately don't solve
- Cross-proceeding edges (G7 plus old
is_spawn). Modelled as project-tree spawns instead. Defer until users complain. (Aproceeding_event_edges.cross_to_proceeding_def_id uuidcolumn would re-open the model, but it muddies the closed-graph invariant. Skip.) - Track-switching at proceeding level (G7 ascended). "If with_ccr, the WHOLE proceeding follows alternate sub-graph rooted at node X." Not modelled — instead, every edge in the alternate sub-graph carries
if_flags=['with_ccr']. Verbose but explicit. If the verbosity becomes painful (more flag-conditional sub-graphs in DE_INF_BGH cross-appeals?) revisit with apaliad.proceeding_tracksoverlay table that groups edges into named tracks. - First-class
paliad.proceedingsinstance row. Per Q2 lock — project IS the instance. If a future feature needs richer instance state (current-stage event_type_id, paused_at, last_advanced_event), columns extendpaliad.projectsdirectly. If that bloats the projects table beyond comfort, a separatepaliad.project_proceeding_state1:1 side-table is the right surgery — but not today. - Schema RLS on the rule library. Today
paliad.deadline_rulesis reference data, readable to any authenticated user, writable only via migrations. The new tables inherit that posture (no RLS, service-role-only writes). If a future world has firm-private overrides (HLC's house policy on a Frist), revisit. - Generic event-types beyond procedural (contract renewal, IP renewal). These live in
paliad.event_types(the instance-side classifier). They will not becomeproceeding_event_typesrows because they don't belong to a proceeding-DAG. Two layers, two purposes — explicitly OK.
6.5 What if m wanted to go bigger — what's the ceiling
The locked design is appropriately ambitious — addresses every gap in §2 except G7 (track-switching, deferred per §6.4). A more-ambitious target shape would:
- Make instance state first-class (
paliad.proceedingstable, real timeline log). Skipped per Q2. - Make conditions a typed expression DSL. Skipped per Q4.
- Allow proceeding inheritance / template specialisation (e.g. UPC_INF_with_pi extends UPC_INF, adds 4 nodes). Not asked for.
- Allow cross-court-system cascades (a UPC LD decision triggers the CoA appeal). Skipped per §3.6.
Each of those would be a follow-up design with its own dogma session. None blocks shipping the current design.
7. Open follow-ups for the coder shift
When m greenlights this design and a coder picks up implementation, surface these explicitly so they don't slip:
- Concept slug curation. The 57 → ~57 mapping is mostly mechanical. ~5 cases need legal eyes: cross-cutting concepts (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) where the slug exists but doesn't yet sit on a proceeding-specific node. Resolution: emit a new
proceeding_event_typesrow in EACH proceeding where the cross-cutting Frist applies, all sharing the sameconcept_slug. Multiplies the row count by ~10 per cross-cutter, fine. - Legacy proceeding_types pruning. The 6 unused legacy codes (
INF,REV,CCR,APM,APP,AMD,ZPO_CIVIL) and their 36+4=40 dead rules should NOT migrate toproceeding_definitions. Confirm with m before dropping (they may have been kept "in case"). If yes-drop: M1 SELECT only the active 16 + ZPO_CIVIL (if still desired). - Frontend impact assessment. Pathway-B decision-tree (t-paliad-133 Phase D-1, in production) reads from
event_categories+event_category_conceptsjoined todeadline_concepts. The junction's concept-side rewires from FK to text. Frontend code that fetches concept_slug stays — backend just speaks the same column under a new FK target. - Approval flow integration. t-paliad-138 dual-control approvals (migration 054) wraps deadline mutations with
approval_requests. The newdeadlines.event_type_idcolumn needs to flow throughpayload jsonbcorrectly; today the approval pre-image capturesrule_id. M3 swap touches approval_service.go + ApprovalRequest payload schema. - CalDAV round-trip.
paliad.deadlines.caldav_uid+caldav_etagsurvives. The CalDAV title rendering usesrule_code(already free-text) — no behavioural change. - Holiday-lookup simplification.
internal/services/holidays.gotoday carries a hardcoded map "courts → applicable holiday sets." After M1 (withpaliad.courts.court_typeFK'd) this becomes a JOIN. Refactor as part of M2 or as a follow-up.
8. Recommendation summary
Ship the design. It addresses every structural gap m's framing exposed (G1–G6, deferring G7 explicitly), it lands on locked decisions throughout (Q1–Q5 verbatim from the AskUserQuestion pass), and it costs ~2 weeks of focused coder time across 4 boots with smoke-gates between.
Sequence: wait for feynman's mai/feynman/fristenrechner to land (parallel work, doesn't block this design but does affect the M1 backfill source). Then route Phase M1 to a coder fluent in pgvector + ltree contexts (noether or fritz; cronus excluded per memory cc28a8ad). Phase M2 needs Fristenrechner-deep context — same picker. Phase M3 + M4 mechanical, any coder.
Recommend: open one Gitea tracking issue for each phase under m/paliad, link to this design doc by anchor (#42-phase-m1-additive-build), set them as a 4-step task chain. Mark M4 as gated on M2 + M3 living in production for ≥1 week without rollback.
The right outcome of this design isn't a one-shot 6-week refactor. It's four 3-day-class migrations stretched over 2–3 weeks, each individually shippable, each individually reversible until the M4 drop. That's how the existing paliad rule-library got built (migrations 003 → 062, ~6 month accretion); that's how it should be reshaped.
End of design doc. ~600 lines target — landing at ~750 with code blocks. NO migration files, NO code edits in this branch — only this design doc per the consultant-mode hard rule.