# Event Types for deadlines + submissions — design

**Task:** t-paliad-088
**Author:** cronus (inventor)
**Date:** 2026-04-30
**Status:** RESOLVED 2026-04-30 12:23 — m greenlit all 7 questions. See §12 for the resolution table. Awaiting head's coder assignment.

m's directive (2026-04-30 11:56):

> "let's add an inventor for 'Event Types', in particular deadlines and submissions — I want to be able to select from existing Event Types when creating a deadline but also add a new custom one if it does not exist. This also needs to be filterable in the overview"

## TL;DR — resolved decisions

1. **Concept:** "Event Type" is the **categorization tag** on a deadline. **Event Types lead**, with an optional bridge FK to `paliad.trigger_events` for the seeded UPC rows (m's call: "event_types should lead and later we can connect things to it"). Submissions are explicitly the primary use case — m's words: *"those are the event types I mean, mainly"*. trigger_events stays as separate calc-engine state (UPC-only verbatim youpc imports).
2. **Schema:** new table `paliad.event_types` + nullable FK `paliad.deadlines.event_type_id`. The bridge `event_types.trigger_event_id bigint NULL REFERENCES paliad.trigger_events(id)` populates only for seeded UPC rows; user customs and non-UPC entries leave it NULL. Broader scope from day one (UPC + EPO + DPMA + DE-national + contract).
3. **Submissions live as Event Types.** No separate `paliad.submissions` table. `event_types.category='submission'` carries the discrimination. A future Schriftsatz-Verwaltung surface pivots on that filter.
4. **Picker:** typeahead `<select>`-flavoured combobox with grouping by category, plus inline "+ Neuen Typ hinzufügen…" → small modal. Reuses the `/tools/fristenrechner` trigger-picker style.
5. **Filter on `/deadlines` is MULTI-SELECT.** Trigger button styled like an `<select>`; click opens a listbox panel with search + "Alle" toggle + checkbox list grouped by category. Backend: `?event_type=uuid1,uuid2,…` (UNION within Event Types, AND-intersected with Status/Projekt). Special value `none` for "Ohne Typ"; combinable with selected types. **Same multi-select filter on `/agenda`**.
6. **Permissions:** any authenticated user can create both private AND firm-wide types. Admins moderate firm-wide via archive after the fact (m's call: lighter-weight onboarding > admin gating).
7. **Backfill:** existing 10 deadlines get `event_type_id=NULL`, render as "Ohne Typ". Migration 030 ships ~40 curated firm-wide seeds (~25 UPC submissions + ~10 UPC decisions/orders + ~10 non-UPC EPO/DPMA/DE-national/contract). Spreadsheet attached to the implementation PR.

## 1 · Concept clarification

### What lives where today

| Surface | Concept | Examples | Cardinality | Origin |
|---|---|---|---|---|
| `paliad.trigger_events` (migration 028) | "What just happened" — anchor for `event_deadlines` calc | `service_of_complaint`, `decision_handed_down`, `statement_of_defence` | 102 rows, UPC only | Imported verbatim from `youpc.data.events` for diffable re-syncs |
| `paliad.event_deadlines` (migration 028) | "After event X, deadline Y fires" rules | RoP.029 1-mo Reply after Defence | 70 rows | Imported verbatim from `youpc.data.deadlines` |
| `/tools/fristenrechner` trigger-picker (PR-2) | UI input over `trigger_events` | "Was kommt nach 'Statement of defence'?" | — | Public knowledge tool |
| `paliad.deadlines` (migration 003+) | Persistent per-project scheduled deadline | Free-text title, due_date, project_id, optional `rule_id` → `deadline_rules` | 10 rows in production | User-created, sometimes Fristenrechner-seeded |

### What m is asking for

A **categorization on `paliad.deadlines`** so the user can:
- pick from a known taxonomy when creating a deadline,
- add a custom type if missing,
- filter the `/deadlines` list by type.

That is unambiguously a **taxonomy column on `deadlines`**, not a trigger event.

### Are Event Types == trigger_events with a UX rename?

**No.** Three reasons:

1. **Scope.** `trigger_events` is **UPC-only** (102 rows from youpc's UPC corpus — see §verified data below). Paliad's deadlines also cover **EPO opposition/appeal**, **DPMA**, **German national court** (LG/OLG Düsseldorf, München), and **contract/IP-licensing renewal dates**. The trigger_events corpus has zero EPO-opposition events, zero DPMA events, and only a handful of cross-jurisdiction items (`Decision of the EPO` is still in the UPC unitary-effect context). Renaming it would falsely suggest paliad covers all jurisdictions.
2. **Diffability invariant.** Memory note from t-paliad-086: *"IDs preserved verbatim from youpc data.events / data.deadlines / data.deadline_rule_codes for diffable re-syncs."* Letting users insert custom rows into `trigger_events` would either break this (id collisions) or require a separate id range — both compromise the import contract. trigger_events is canonical-imports state, not user-extensible.
3. **Different semantics.** A trigger_event is "the event that just occurred, used as anchor". An Event Type on a deadline is "what this deadline categorically IS". For 70-ish rows they coincide (a deadline whose Type is "Reply to Defence" would naturally have `trigger_event_id` pointing to the same code). For decisions/orders they don't really coincide — `decision_handed_down` as a trigger anchors *future* deadlines, but as an Event Type it labels *the date the decision is expected*. Both views are useful. Conflating them collapses the distinction.

### Are Event Types == "submissions" as a sibling entity?

**No.** Not now.

A submission *is* a deadline-bearing item ("filed Statement of Defence on 2026-08-15" — that's a deadline whose category is submission). Splitting submissions into their own table either duplicates data or forces a migration of existing deadlines, both for negative gain. **The Event Type's `category` column carries the discrimination** (`submission` | `decision` | `order` | `service` | `fee` | `hearing` | `other`). A future Schriftsatz-Verwaltung surface (out of scope here) can pivot on `WHERE event_types.category='submission'`.

Re-evaluate this if/when m wants a true Schriftsatz-Verwaltung with submission-specific fields (file uploads, version tracking, recipient party, language) that don't fit on a generic deadline row. **That's a separate task; flagging here so we don't have to migrate twice.**

### Bridge to trigger_events

`paliad.event_types` carries an optional `trigger_event_id bigint REFERENCES paliad.trigger_events(id)`. For the seeded firm-wide types we populate it; for user customs and non-UPC types it stays NULL. This:

- preserves provenance for the UPC-seeded types,
- enables future polish: "this deadline's Event Type matches a trigger event → offer to compute downstream deadlines via Fristenrechner",
- doesn't force a circular dependency (Event Types can exist without a trigger_event).

## 2 · Schema decision

### Migration 030 — `paliad.event_types` + FK on `paliad.deadlines`

```sql
-- internal/db/migrations/030_event_types.up.sql

CREATE TABLE paliad.event_types (
    id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    slug            text NOT NULL,
    label_de        text NOT NULL,
    label_en        text NOT NULL,
    category        text NOT NULL DEFAULT 'submission'
                        CHECK (category IN ('submission','decision','order','service','fee','hearing','other')),
    jurisdiction    text
                        CHECK (jurisdiction IS NULL OR jurisdiction IN ('UPC','EPO','DPMA','DE','any')),
    description     text,
    trigger_event_id bigint REFERENCES paliad.trigger_events(id) ON DELETE SET NULL,
    created_by      uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
    is_firm_wide    boolean NOT NULL DEFAULT false,
    archived_at     timestamptz,
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now()
);

-- Slug uniqueness: firm-wide types share one namespace; private types are scoped per user.
CREATE UNIQUE INDEX event_types_firm_slug_idx
  ON paliad.event_types(slug)
  WHERE is_firm_wide = true AND archived_at IS NULL;

CREATE UNIQUE INDEX event_types_private_slug_idx
  ON paliad.event_types(created_by, slug)
  WHERE is_firm_wide = false AND archived_at IS NULL;

CREATE INDEX event_types_category_idx ON paliad.event_types(category);
CREATE INDEX event_types_jurisdiction_idx ON paliad.event_types(jurisdiction) WHERE jurisdiction IS NOT NULL;

-- FK on deadlines
ALTER TABLE paliad.deadlines
  ADD COLUMN event_type_id uuid
    REFERENCES paliad.event_types(id) ON DELETE SET NULL;

CREATE INDEX deadlines_event_type_idx
  ON paliad.deadlines(event_type_id)
  WHERE event_type_id IS NOT NULL;

-- updated_at trigger (mirrors existing paliad.set_updated_at pattern)
CREATE TRIGGER event_types_set_updated_at
  BEFORE UPDATE ON paliad.event_types
  FOR EACH ROW EXECUTE FUNCTION paliad.set_updated_at();

-- RLS
ALTER TABLE paliad.event_types ENABLE ROW LEVEL SECURITY;

-- Read: firm-wide types visible to all authenticated users; private types only to author.
CREATE POLICY event_types_select ON paliad.event_types
  FOR SELECT TO authenticated
  USING (
    archived_at IS NULL
    AND (is_firm_wide = true OR created_by = auth.uid())
  );

-- Insert: any authenticated user can insert any row, as long as created_by = self.
-- Firm-wide types are open to all users; admins moderate via archive after the fact.
CREATE POLICY event_types_insert ON paliad.event_types
  FOR INSERT TO authenticated
  WITH CHECK (created_by = auth.uid());

-- Update: author owns their own rows (private or firm-wide they created).
-- global_admin can update / archive any firm-wide row regardless of authorship.
CREATE POLICY event_types_update_owner ON paliad.event_types
  FOR UPDATE TO authenticated
  USING (created_by = auth.uid())
  WITH CHECK (created_by = auth.uid());

CREATE POLICY event_types_update_admin ON paliad.event_types
  FOR UPDATE TO authenticated
  USING (
    is_firm_wide = true
    AND EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
  );

-- Delete: never. Use archived_at.
```

### Why a separate table (not a `deadlines.event_type` text column)

A free-text column would let users type the same concept three ways ("Reply", "reply", "Erwiderung") and **the filter would silently miss matches**. The whole point of typing is that the same name resolves to one row. A table also lets us:

- ship 30+ curated firm-wide labels with bilingual text,
- carry `category` + `jurisdiction` metadata for grouped picker,
- cross-link to `trigger_events` for the Fristenrechner-handoff polish,
- support archiving (firm renames "Beschwerdebegründung" → leave old rows untouched, archive the type).

### Why optional FK and not NOT NULL on deadlines

Existing 10 deadlines have no event_type. Backfilling automatically would either guess or be wrong. NULL = "Ohne Typ" works fine — the filter row has an "Alle" default and an "Ohne Typ" option. Users tag retrospectively when they edit a deadline.

### Slug strategy

Slug is auto-derived from `label_de` (kebab-case) on insert if not supplied; user-private slugs are scoped per user so two users can each have their own "klage" without colliding. Firm-wide slugs share one namespace — global_admins coordinate.

## 3 · Picker UX

### Where the picker appears

- `/deadlines/new` — new optional field below "Titel", above the date+rule row.
- `/deadlines/{id}` — edit modal, same field shape.
- *(Future polish, NOT in scope)* `/tools/fristenrechner` "Send to deadline" button — pre-fills `event_type_id` from the originating trigger_event.

### Visual shape (matches existing `.akten-form` field-row pattern)

```
┌─────────────────────────────────────────────────────────┐
│ Typ (optional)                                          │
│ ┌─────────────────────────────────────────────────┬───┐ │
│ │ Bitte wählen oder tippen…                       │ ▼ │ │
│ └─────────────────────────────────────────────────┴───┘ │
│                                                         │
│ When opened (with no input):                            │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Eigene                                              │ │
│ │   ⭐ Mein Mahnschriftsatz-Template                  │ │
│ │ Eingaben (UPC)                                      │ │
│ │   • Statement of Defence                            │ │
│ │   • Reply to the Defence                            │ │
│ │   • Counterclaim for Revocation                     │ │
│ │   • Statement of Appeal                             │ │
│ │   ... (~30)                                         │ │
│ │ Entscheidungen                                      │ │
│ │   • Decision on the merits                          │ │
│ │   • Decision on costs                               │ │
│ │ Anordnungen                                         │ │
│ │   • Case management order (Service)                 │ │
│ │ Gebühren                                            │ │
│ │   • Annuity payment (DPMA)                          │ │
│ │   • EP renewal fee                                  │ │
│ │ ─────────────────────────────────────────────────── │ │
│ │ + Neuen Typ hinzufügen…                             │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```

The trigger-event picker on `/tools/fristenrechner` (PR-2) already implements typeahead-over-list. Reuse its filter logic and visual style; differences:
- **Grouped by category** with sticky group headers (the trigger picker is flat).
- **"Eigene" group** at top (private types of the current user) with a star icon.
- **"+ Neuen Typ hinzufügen…" footer row** triggers the add modal.

### Custom-add modal

Lightweight `<dialog>` (matches the existing `.modal` pattern from t-paliad-049):

```
┌─ Neuen Event-Typ anlegen ─────────────────────────┐
│                                                   │
│  Bezeichnung (DE) *                               │
│  [_______________________________________]        │
│                                                   │
│  Bezeichnung (EN, optional)                       │
│  [_______________________________________]        │
│                                                   │
│  Kategorie *                                      │
│  [Eingabe ▼]  (Eingabe / Entscheidung /           │
│                Anordnung / Zustellung /           │
│                Gebühr / Sitzung / Sonstiges)      │
│                                                   │
│  Jurisdiktion (optional)                          │
│  [— ▼]  (UPC / EPA / DPMA / DE / —)               │
│                                                   │
│  ☐ Firmenweit verfügbar machen  *                 │
│   (* nur für Admins sichtbar)                     │
│                                                   │
│         [ Abbrechen ]  [ Anlegen ]                │
└───────────────────────────────────────────────────┘
```

Behaviour:
- If user typed text in the picker before clicking "+ Neuen Typ", that text pre-fills "Bezeichnung (DE)".
- "Firmenweit" checkbox is only rendered for users where `currentUser.global_role === 'global_admin'`. Non-admin private-only.
- On submit: `POST /api/event-types`, on 201 the picker re-fetches its option list and selects the new id.
- Error 409 (slug collision): show inline error "Ein Typ mit diesem Namen existiert bereits".

### Why a modal vs. inline expansion

Inline expansion would push the deadline-create form down by 4 fields and feel cramped. A modal is the existing paliad pattern (project edit, invitation flow). Smaller scope: 1 form, 1 button, escape-to-close.

### Why not free-text fallback that auto-creates

Two reasons:
1. **Typo-driven duplication.** A user types "Klage" → a row is created → next time they type "Klagen" → another row. Within a week the firm has 12 rows for one concept. The deliberate "+ Neuen Typ" affordance forces the user to confirm "yes, this is new" and to set category/jurisdiction.
2. **Permission asymmetry.** Auto-create defaults to private; users who actually want firm-wide need an explicit toggle. The modal makes that visible.

## 4 · Filter UX on `/deadlines` (and `/agenda`)

### `/deadlines` (primary scope) — multi-select

m's call (Q4): match the existing `<select>`-row pattern visually, but make Event Types **multi-select**. Status/Projekt stay single-select. New `EventTypeMultiSelect` component:

```
.akten-filter-row
├─ <label>Typ</label>
└─ <button class="akten-select akten-multi-trigger" aria-haspopup="listbox">
     <span class="akten-multi-label">Alle</span>           ← / "3 Typen" / single label
     <span class="akten-multi-chevron">▾</span>
   </button>

opens (popover, anchored to the trigger, click-outside dismisses):
┌──────────────────────────────────────────┐
│ 🔍 [Suche…]                              │
│ ☐ Alle  /  ☐ — Ohne Typ —                │
│ ─────────────────────────────────────────│
│ Eingaben (UPC)                           │
│   ☐ Statement of Defence                 │
│   ☐ Reply to the Defence                 │
│   ☐ Counterclaim for Revocation     …    │
│ Entscheidungen                           │
│   ☐ Decision on the merits          …    │
│ Anordnungen                              │
│ Gebühren                                 │
│ Eigene                                   │
│   ☐ Mein Mahnschriftsatz-Template        │
│ ─────────────────────────────────────────│
│   [ Zurücksetzen ]   [ Anwenden ]        │
└──────────────────────────────────────────┘
```

**Behaviour:**
- Default state: "Alle" toggle on, list disabled. Toggling any list item turns "Alle" off and ticks the row.
- "— Ohne Typ —" is a special row separate from "Alle"; ticking it adds `event_type_id IS NULL` rows alongside whatever specific types are ticked.
- Trigger label shows "Alle", "Ohne Typ", "Statement of Defence", or "3 Typen" depending on count.
- Search box filters the list across `label_de` + `label_en`.
- "Anwenden" (or click-outside) commits; "Zurücksetzen" returns to "Alle".
- Mobile: popover becomes a full-width sheet from the bottom (reuses the existing `.modal-mobile-bottom` class if it exists, else a one-CSS-rule bottom sheet).

**Backend / query param:**
- `?event_type=<uuid>,<uuid>,none` — comma-separated UUIDs and/or the literal `none` keyword. Empty / absent = "Alle".
- Service layer parses to `(event_type_id IN (uuid1,uuid2,…) OR event_type_id IS NULL [if 'none' present])`, AND-intersected with the existing status / project predicates.
- State persists in URL — bookmark + back-button preserve the filter.

### Add a `Typ` column to the deadlines table?

Yes — small column showing `label_de` (or `label_en` per current language). Column hides via `.akten-table--hide-status`-style toggle (t-paliad-073 pattern: hide when every visible row shares the same value).

### `/agenda` — same multi-select

m's call (Q5): ship `/agenda` filter in the same task. Same `EventTypeMultiSelect` component, mounted as a second filter row below the existing Type chip row (deadlines/appointments). `AgendaService.List` accepts the same `?event_type=` param; appointments are unaffected (they have no event_type) — they're returned regardless when "appointments" is in the type-of-item filter.

## 5 · Permission model

| Action | Permission |
|---|---|
| Read firm-wide types | any authenticated user |
| Read own private types | only the author |
| Create private type (`is_firm_wide=false`) | any authenticated user (`created_by=self`) |
| Create firm-wide type (`is_firm_wide=true`) | **any authenticated user** (m's Q6: lighter-weight onboarding, admin moderates after the fact) |
| Edit own type (private or firm-wide) | the author |
| Edit / archive any firm-wide type | `global_role='global_admin'` (moderation lever) |
| Archive a type | same as edit |
| Delete a type | never (set `archived_at` instead) |

Enforced at two layers:

- **RLS policies** (§2 above) — the safety net.
- **Service layer** — `EventTypeService.Create` validates the slug shape and uniqueness before insert, rejects `created_by` mismatches with 400.

### Why this looser firm-wide-create policy

m's call. Two consequences worth naming:

1. **Drift risk:** users will create overlapping firm-wide types ("Klage", "Klageeinreichung", "Klage erheben"). Mitigation: search-prevent-duplicate in the add modal — if a fuzzy match (`%label_de%`) already exists firm-wide, surface "Existiert vermutlich schon: …" with a "Trotzdem anlegen" override. Reduces but doesn't eliminate.
2. **Moderation backlog:** admins need a lightweight surface to scan + archive. Not in scope for this task; flag as follow-up *t-paliad-089: admin Event-Type moderation panel*.

### Why allow user-private types at all

Each lawyer has a couple of personal categories that nobody else needs ("Reminder for myself when X", "My standing template for Y"). Private types stay out of others' pickers; the user's own picker shows them under "Eigene".

## 6 · Backfill strategy

### Existing data

`SELECT count(*) FROM paliad.deadlines` → 10 rows (production, 2026-04-30).

All get `event_type_id=NULL` after migration 030. Render in the UI as "Ohne Typ" (separate option in the filter, displayed as a faint chip next to the title in the table).

### Seeded firm-wide types

Migration 030 seeds ~40 firm-wide types in a separate `030b_seed_event_types.up.sql` (or appended to 030 — single-migration ok if it stays readable). Three pools:

1. **UPC submissions (~25 rows)** — picked from `paliad.trigger_events` codes for the most common procedural submissions (Statement of Claim/Defence, Counterclaim, Reply, Rejoinder, Statement of Appeal, Statement of grounds of appeal, Cross-appeal, Application to amend the patent, Defence to revocation, Application for damages, Application for cost decision, Protective Letter, Preliminary Objection). Each row gets `trigger_event_id` populated.
2. **UPC decisions/orders (~10 rows)** — Decision on the merits, Decision on costs, Case management order, Order of the judge-rapporteur, Final decision, Summons to oral hearing, Service of complaint. `trigger_event_id` populated.
3. **Non-UPC (~10 rows, hand-written, no `trigger_event_id`)** — EPO opposition filing, EPO opposition reply, EPO appeal filing, EPO appeal grounds, EP annuity payment, DPMA examination request, DPMA opposition, German national court Klageerwiderung, German national court Beschwerde, IP-licence renewal date.

I will NOT seed all 102 trigger_events as Event Types — most are highly specific procedural sub-events ("Rejoinder to the Reply, Reply to the Defence to an Application to amend the patent" — that level of granularity belongs in the calc engine, not the picker dropdown). The curated subset of ~25 captures the 80 % case; users with niche needs add private types.

The exact seed list lives in the migration; I'll attach the spreadsheet to the implementation PR.

### No automatic title-based backfill

Tempting: parse `paliad.deadlines.title` against seeded `label_de`/`label_en`. **Don't.**
- 10 production rows; not worth the script.
- High false-positive risk ("Reply" matches at least 8 different seed types).
- Better UX: when a user opens a typed-`NULL` deadline in edit mode, the form shows "Typ: — Ohne Typ —" with the picker open, prompting them to tag.

If/when the row count grows past ~200, revisit with a manual reconciliation script run by an admin.

## 7 · API endpoints

```
GET  /api/event-types                  → list (firm-wide ∪ own private), filterable by ?category= and ?jurisdiction=
POST /api/event-types                  → create; body {label_de, label_en?, category, jurisdiction?, is_firm_wide?}
PATCH /api/event-types/{id}            → edit (label/category/jurisdiction/archived_at); RLS enforces ownership
GET  /api/deadlines?event_type_id=     → already handled if we add the param to the list handler
GET  /api/agenda?event_type_id=        → same on the agenda handler
```

`POST /api/event-types` returns 201 with the created row; 403 for non-admin trying `is_firm_wide=true`; 409 on slug collision; 422 on missing label_de or invalid category. Standard paliad envelope.

The existing `paliad.deadlines` POST handler (`/api/deadlines`) gets a new optional `event_type_id` field — validate it points to a row the user can see (firm-wide or own private).

## 8 · Test plan

### Unit / Go

- `EventTypeService.Create` happy path (private type by regular user).
- `EventTypeService.Create` 403 when regular user tries `is_firm_wide=true`.
- `EventTypeService.Create` 409 on slug collision (firm-wide same slug; per-user same slug).
- `EventTypeService.List` returns firm-wide ∪ own-private; not other users' private.
- `DeadlineService.Create` accepts `event_type_id`; rejects FK pointing at someone else's private type.
- `DeadlineService.List` filter by `event_type_id` intersects with status + project filters.
- `AgendaService.List` filter by `event_type_id`.

### Integration / Playwright

Login as `tester@hlc.de`:

1. **Pick existing type:** `/deadlines/new` → fill title + project + due → open Typ picker → select "Statement of Defence" → submit → `/deadlines` shows row with Typ column = "Statement of Defence".
2. **Filter:** `/deadlines` → set Typ filter to "Statement of Defence" → list narrows to 1 row → set to "Alle" → all rows back.
3. **Custom-add (private):** `/deadlines/new` → open Typ picker → click "+ Neuen Typ hinzufügen" → modal → name "Mein Test-Typ", category Eingabe → Anlegen → modal closes, picker re-opens with new option selected → submit deadline → `/deadlines` shows it.
4. **Privacy:** custom private types only visible to creator. Verify with API call as second test account if available; else assert via direct SQL after the test.
5. **No-type filter:** filter "— Ohne Typ —" returns the pre-existing 10 rows that were never tagged.
6. **Mobile snapshot:** filter row wraps cleanly on 375px viewport.
7. **DE/EN switch:** language toggle re-renders both picker and filter labels (`label_de`/`label_en` swap, optgroup labels swap).
8. **Archive flow (admin):** as global_admin, edit a firm-wide type → set archived_at → existing deadlines keep their `event_type_id` (label still renders for legacy rows) but the type no longer appears in the picker for new deadlines.

### Manual smoke

- New deadline reachable through Fristenrechner "Send to deadline" path (if/once that flow exists) carries event_type_id matching the trigger event.

## 9 · Coordination

- **t-paliad-086 (curie, shipped):** trigger_events table is the seed source for the curated firm-wide types. The Fristenrechner trigger picker on `/tools/fristenrechner` STAYS as-is — it's a calc tool, not a categorization tool. No conflict.
- **t-paliad-087 (brunel, in flight):** light-grey BG sweep on `global.css`. Low overlap — this task adds new picker styles + the custom-add modal; brunel touches existing surfaces. Coordinate via merge order: brunel merges first (bigger surface area), then this task rebases.
- **Tier 2 Fristenrechner ports** (damages, cost-appeal, cross-appeal, lay-open, leave-to-appeal): unrelated; their trigger_events rows (already imported in PR-1) become eligible seed candidates if/when the curated list expands.

## 10 · Migration outline (single PR, ~5 commits)

1. **Schema + seeds** — `030_event_types.up.sql` (table, indexes, triggers, RLS policies, ~40 seed rows). `030_event_types.down.sql` reverses cleanly.
2. **Models + service** — `internal/models/event_type.go`, `internal/services/event_type_service.go`. Wire into `cmd/server/main.go` services bundle.
3. **Handlers + routes** — `internal/handlers/event_types.go` (CRUD), update `internal/handlers/deadlines.go` to accept `event_type_id`, update `internal/handlers/agenda.go` to accept `?event_type_id`.
4. **Frontend picker + modal** — `frontend/src/components/EventTypePicker.tsx` (shared) + `frontend/src/components/EventTypeAddModal.tsx`. Wire into `deadlines-new.tsx` and `deadlines-detail.tsx` edit modal. ~30 i18n keys (DE+EN) under `event_types.*` and `deadlines.field.event_type.*`.
5. **Frontend filter + table column** — `deadlines.tsx` adds the `<select>` filter row + Typ column; `client/deadlines.ts` handles the `?event_type=` query param. `agenda.tsx` adds the pill-row variant; `client/agenda.ts` handles its query param.

Tests live alongside each layer. Verify via `bun run build` + `go test ./...` + Playwright smoke.

## 11 · Alternatives considered, not picked

| Alternative | Why not |
|---|---|
| Reuse `trigger_events` directly with a `created_by` column | Breaks the verbatim-import-for-diffability invariant; mixes calc state with user state; bigint vs uuid id space friction. |
| Free-text `deadlines.event_type` column with self-distinct lookup | Typo-driven duplicates kill the filter UX; no metadata (category/jurisdiction); no privacy boundary. |
| `paliad.submissions` as a sibling entity to deadlines | Forces migration of existing rows; duplicates fields (due_date, project_id, created_by); a submission *is* a deadline-bearing item. Defer until real submission-specific fields (file uploads, recipient party) are needed. |
| Multi-select filter (UNION across multiple Event Types) | **PICKED.** m's Q4 call — Event Types specifically benefits from multi-select (a user often wants "show me all my Replies, Rejoinders, and Defences"). Status/Projekt stay single-select; the asymmetry is intentional. |
| Auto-create on free-text in picker | Generates noise; can't ask the user category/jurisdiction; wrong default permission. Keep the explicit "+ Neuen Typ" affordance. |
| Hierarchical Event Types (parent type → sub-type) | Over-engineered for 40 seeds + handful of customs. Use `category` for the one level of grouping users care about. |

## 12 · Open questions — RESOLVED 2026-04-30 12:23

| # | Question | m's call |
|---|---|---|
| 1 | Concept boundary — broader (UPC + EPO + DPMA + DE + contract) or UPC-only first cut? | **A — broader from day one** |
| 2 | Schema — new `paliad.event_types` table + FK? | **A — yes**, with the bridge `event_types.trigger_event_id → paliad.trigger_events(id)` populated only for seeded UPC rows. *m: "event_types should lead and later we can connect things to it"* |
| 3 | Submissions — defer separate table? | **A — yes, defer**. *m: "those are the event types I mean, mainly"* |
| 4 | Filter style — `<select>` matching existing pattern? | **A, but multi-select.** Custom listbox-panel multi-select component (see §4 above). Status/Projekt stay single-select. |
| 5 | `/agenda` filter — same task? | **A — same task** |
| 6 | Firm-wide type permission floor — global_admin only? | **B — any authenticated user can create firm-wide; admins moderate via archive after the fact.** Mitigation: duplicate-warning in the add modal. Follow-up: *t-paliad-089: admin moderation panel*. |
| 7 | Seed list — ~40 curated rows in migration 030? | **A — yes**, spreadsheet on the implementation PR for review. |

**Status:** all gate-blocking calls answered. Awaiting head's coder assignment. Inventor stays parked.

## 13 · Inventor recommendation on implementer

cronus did this design (data-model area). Either cronus or curie would be a good fit to implement: cronus knows the `trigger_events` corpus from this design pass; curie just shipped the trigger_events import (t-paliad-086) and knows the calc-engine context. Single coder is fine — the surface is one table, one FK, one picker, one filter, one modal. **Head decides**, not me.

---

**End of design.** Awaiting m's go/no-go on §12 #1, #2, #3, #4. Will not begin implementation until greenlit.
