# 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 `verfahren` projects but nullable in the schema).
- ONE court — but as **free text**, not FK'd to `paliad.courts.id` despite 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_date` of 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): walks `deadline_rules` parent_id chain, anchors on input trigger_date, applies condition_flag gates, swaps `alt_*` 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/fristenrechner` for 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. Composite `combine_op='max'`/`'min'` resolves R.198/R.213. Working-days math via `addWorkingDays` over `paliad.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 in `event_deadlines` were 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)

```sql
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)

```sql
-- 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)

```sql
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)

```sql
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)

```sql
-- 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 to `proceeding_event_types.concept_slug` + `concept_de` + `concept_en` + `aliases`.
- `paliad.deadline_rules.concept_id` FK. Replaced by `proceeding_event_types.concept_slug` text column.
- `paliad.trigger_events.concept_id text` (already a slug, was never an FK). Migrated to the matching `proceeding_event_types` rows — see §4.

**Stays:**
- `paliad.event_categories` (103 rows) — Pathway-B navigation taxonomy. Recursive tree, decision-tree UI. Re-FK its junction onto `concept_slug`:

```sql
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 on `paliad.deadlines`. Per t-paliad-088 this is firm-wide-or-private, archive-only, with optional loose-linkage `trigger_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.

```mermaid
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`:**

```sql
-- 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_event` becomes a node (concept_slug from the existing slug column).
- Each `event_deadline` becomes 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 ONE `rule_code` per edge. Per row, pick `sort_order=0` as the canonical citation; remaining 0-2 codes per edge become a separate `paliad.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_id` retired entirely. The legal citation already lives in `deadlines.rule_code text` (per t-paliad-111). The structural pointer becomes `deadlines.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.

```sql
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)

```sql
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 with `concept_slug='statement-of-defence'` or matching aliases.
- `paliad.proceeding_event_types.aliases text[]` — the search vocabulary. Lifts directly from old `deadline_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_types` rows 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_edges` for 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_de` of `proceeding_event_types`, returns the matched concept_slug (`hinweisbeschluss-stellungnahme`), and uses the project's `proceeding_def_id` to 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-node `description text` (already exists, just needs population).
- `category` (submission/decision/order/hearing) — survives (`kind` column on node).
- `party` — survives (`party` column 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's `condition_rule_id` + `condition_flag` mix 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.regime` directly produce the holiday set.
- **Extensibility for future condition kinds without further migrations.** New typed columns can be added incrementally (e.g. `min_business_days int` for "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. (A `proceeding_event_edges.cross_to_proceeding_def_id uuid` column 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 a `paliad.proceeding_tracks` overlay table that groups edges into named tracks.
- **First-class `paliad.proceedings` instance 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 extend `paliad.projects` directly. If that bloats the projects table beyond comfort, a separate `paliad.project_proceeding_state` 1:1 side-table is the right surgery — but not today.
- **Schema RLS on the rule library.** Today `paliad.deadline_rules` is 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 become `proceeding_event_types` rows 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.proceedings` table, 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:

1. **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_types` row in EACH proceeding where the cross-cutting Frist applies, all sharing the same `concept_slug`. Multiplies the row count by ~10 per cross-cutter, fine.
2. **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 to `proceeding_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).
3. **Frontend impact assessment.** Pathway-B decision-tree (t-paliad-133 Phase D-1, in production) reads from `event_categories` + `event_category_concepts` joined to `deadline_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.
4. **Approval flow integration.** t-paliad-138 dual-control approvals (migration 054) wraps deadline mutations with `approval_requests`. The new `deadlines.event_type_id` column needs to flow through `payload jsonb` correctly; today the approval pre-image captures `rule_id`. M3 swap touches approval_service.go + ApprovalRequest payload schema.
5. **CalDAV round-trip.** `paliad.deadlines.caldav_uid` + `caldav_etag` survives. The CalDAV title rendering uses `rule_code` (already free-text) — no behavioural change.
6. **Holiday-lookup simplification.** `internal/services/holidays.go` today carries a hardcoded map "courts → applicable holiday sets." After M1 (with `paliad.courts.court_type` FK'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.*
