Compare commits

..

11 Commits

Author SHA1 Message Date
mAi
9d688459e3 feat(db): mig 153 — proceeding_types kind discriminator + ProjectService hardening
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Adds a `kind` column to paliad.proceeding_types (proceeding / phase /
side_action / meta) so the Mode B R3 Fristenrechner wizard, the
projects.proceeding_type_id binding, and the pkg/litigationplanner
snapshot can filter to primary proceedings only.

Implements the ratified design from docs/design-proceeding-types-
taxonomy-2026-05-26.md (m greenlit 2026-05-27 09:57 after the 11-question
AskUserQuestion round-trip).

Mig 153 is purely additive — ADD COLUMN with a safe DEFAULT, UPDATEs
reclassify 23 non-primary rows (4 phase + 10 side_action + 9 meta), and
a BEFORE INSERT/UPDATE trigger on paliad.projects backstops the new
invariant. Pre-mig audit (Supabase MCP, 2026-05-27) confirmed zero
downstream pressure on the 23 reclassified rows.

- internal/db/migrations/153_proceeding_types_kind.up.sql + .down.sql
  - snapshot to paliad.proceeding_types_pre_153 in the same TX
  - set_config('paliad.audit_reason', …) defensively
  - DO-block asserts 23 reclassified rows before the trigger ships
  - Q9 carve-out: is_active=false on every phase/side_action/meta row
  - new trigger paliad.projects_proceeding_type_kind_check on
    paliad.projects.proceeding_type_id

- internal/services/project_service.go
  - extend validateProceedingTypeCategory to also enforce
    kind='proceeding' AND is_active=true; new typed error
    ErrInvalidProceedingTypeKind
  - single SELECT picks up category + kind + is_active

- internal/services/project_service_test.go
  - TestProjectService_ProceedingTypeKindGuard covers service-layer
    rejection, the active-but-non-proceeding edge, mig 153 trigger
    backstop, and the kind='proceeding' happy path

- cmd/gen-upc-snapshot/main.go
  - filter proceeding_types query to kind='proceeding' for forward-
    compat (the embedded UPC snapshot JSON regen requires DATABASE_URL
    access and will land in a follow-up; the current placeholder is
    already empty of non-primary rows)

t-paliad-325 / m/paliad#147
2026-05-27 10:09:33 +02:00
mAi
058a36976b Merge: t-paliad-324 — proceeding_types taxonomy design doc (docs only) (m/paliad#147)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
atlas shipped the 580-line design ratifying Model 1 (kind discriminator) for the proceeding_types cleanup. All 11 PRDs answered by m in §10.

Final categorisation (46 active rows):
- 23 kind='proceeding' (18 with corpus + 5 unloaded primaries incl. upc.costs.cfi per m's Q2 carve-out)
- 4 phase (upc.cfi.interim/oral/decision + upc.default.cfi)
- 10 side_action (evidence/experiments/security/intervention/parties/optout/inspection/freezing/withdrawal/rehearing)
- 9 meta (case.mgmt, general.rop, service, language, representation, fees, legalaid, special, reestablishment)

Mig 153 sketch (per §3): ADD COLUMN kind text NOT NULL DEFAULT 'proceeding' CHECK in {proceeding,phase,side_action,meta}; 4 UPDATEs setting kind for the non-primary IDs; optional CHECK trigger blocking projects.proceeding_type_id from referencing non-proceeding kinds. No row moves, no FK churn — 0 downstream rules / projects / spawn FKs / concepts point at non-primary rows today (verified live, §0.1).

Sequencing (m's Q10): parallel-land with knuth's S3 of the Fristenrechner overhaul. The kind column makes Mode B R3's WHERE filter trivial; no need to serialize.

Coder gate held — atlas parks; head dispatches a fresh Sonnet coder for mig 153 + ProjectService.SetProceedingType hardening + youpc-go snapshot regen.
2026-05-27 09:55:52 +02:00
mAi
3219bff4d4 design(taxonomy): proceeding_types kind discriminator + 11 m's decisions (t-paliad-324)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Live audit established that 28 of 46 active proceeding_types have zero
downstream pressure (0 rules, 0 projects, 0 spawn FKs, 0 concepts). Mig
plan is purely additive: ADD COLUMN kind text CHECK (...), four UPDATE
statements to tag phase/side_action/meta rows, deactivate them, and add
a BEFORE INSERT/UPDATE trigger on projects.proceeding_type_id to enforce
kind='proceeding'.

m's call on the 11 AskUserQuestion decisions:
- Model 1 (kind discriminator)
- Phases implicit via procedural_events.event_kind, EXCEPT upc.costs.cfi
  stays kind='proceeding' (standalone R.151 application)
- Side-actions: kind='side_action', rules anchor on parent primary
- Schutzschrift kind='proceeding' (own RoP filing)
- DE inf + DE null + DE-vs-upc.apl unification: all keep discrete
- upc.ccr.cfi: keep status quo per t-paliad-204 S1
- DB trigger on projects only (admin-only writes on sequencing_rules)
- Deactivate non-primary rows (23 active post-mig, all kind='proceeding')
- Parallel-land vs m/paliad#146 — knuth's S3 picks up the filter

Final categorisation: 23 proceeding / 4 phase / 10 side_action / 9 meta.

No code yet — coder gate held per inventor SKILL. Design only.

Closes the inventor pass on m/paliad#147.
2026-05-27 09:54:18 +02:00
mAi
081b66ebc8 Merge: t-paliad-323 Slice S2 — Fristenrechner result view under ?overhaul=1 (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped S2 of the Fristenrechner overhaul (design §4, §7-S2):

- New frontend/src/client/fristenrechner-result.ts (611 LoC) — renders the shared result view: trigger card (sticky header, inline date editor), 4 priority groups (Mandatory / Recommended / Optional / Conditional) with SPAWNED badge per §4.2, per-rule rows with checkbox + inline date override + party/citation badges, write-back footer conditional on project!=null (§11.Q7 — kontextfrei mode shows informational nudge instead).
- 72-LoC test suite covers groupFollowUps + defaultChecked semantics.
- Page wiring: ?overhaul=1 query param mounts the result view in place of the legacy renderProcedureResults; both coexist this slice. Deep-link shape: ?overhaul=1&event=<code>&trigger_date=…&project=… per §5.
- audit_reason wording in the bulk write-back call: 'Aus Fristenrechner — Trigger: {name} ({date})' per §11.Q12.
- 340 LoC of new CSS (entity-table extensions, group dividers, badge tokens).
- bun build clean; 249 existing frontend tests + 9 new pass; go build + vet clean; S1 live-DB tests still green.

PAUSED AT SEAM — knuth parked persistent. S3+ (Mode A/B wizard chips) waits for the proceeding_types taxonomy redesign (m/paliad#147, atlas in flight) to ratify the qualifier set that R3 picks from.
2026-05-26 22:09:59 +02:00
mAi
9ab8dd8e0f feat(fristenrechner): Slice S2 — result view under ?overhaul=1 (m/paliad#146)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
New `frontend/src/client/fristenrechner-result.ts` module renders the
shared result surface defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §4:

  * Sticky trigger card — event icon + name, proceeding/jurisdiction
    chips, inline trigger-date input that re-fetches on change.
  * Four follow-up groups — Mandatory / Recommended / Optional /
    Conditional. SPAWNED rules fold into their priority bucket with
    a `⇲ neues Verfahren` badge (§11.Q5). Conditional bucket holds
    every rule with sr.condition_expr IS NOT NULL.
  * Per-rule rows — title, duration phrase, party chip, legal-source
    citation (with youpc.org link when available), pre-checked
    checkbox driven by `defaultChecked(r)` (mandatory + recommended
    on; conditional + court-set + optional off), inline ✏ Datum
    override that re-renders.
  * Write-back footer — conditional on `?project=<uuid>` per §11.Q7;
    in kontextfrei mode the footer is hidden and an inline nudge
    invites the user to pick an Akte. CTA submits to the existing
    POST /api/projects/{id}/deadlines/bulk endpoint, stamping each
    row with `audit_reason: "Aus Fristenrechner — Trigger: {name}
    ({date})"` per §11.Q12.

Mount + URL contract — when `?overhaul=1` is set in the URL,
`fristenrechner.ts` hides every legacy panel (`fristen-step1`,
`fristen-step2`, `fristen-pathway-a`, `fristen-pathway-b`,
`fristen-step3a`, the step-1 summary) and shows the overhaul root
instead. With `?overhaul=1&event=<code>&trigger_date=…` the surface
is deep-linkable end-to-end. Without `?event=` the empty-shell
nudge renders — S3+S4 will mount the entry-mode UIs onto this same
root.

Verified — bun build clean, 249 frontend tests pass (incl. 9 new
helper tests for groupFollowUps + defaultChecked), go build + vet
clean, S1 live-DB tests still green.
2026-05-26 22:09:27 +02:00
mAi
4218d9cb52 Merge: t-paliad-323 Slice S1 — Fristenrechner backend endpoints (m/paliad#146)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
knuth shipped S1 of the Fristenrechner overhaul (docs/design-fristenrechner-overhaul-2026-05-26.md §6, §7-S1):

- GET /api/tools/fristenrechner/search?kind=events — returns procedural_events tuples with trigram ranking + follow-up counts (alongside the existing concept-card response). New service: services/fristenrechner_search_events.go (257 LoC).
- GET /api/tools/fristenrechner/follow-ups — given trigger event + date + optional party qualifier, returns sequencing_rules anchored on the event with computed due dates via pkg/litigationplanner.CalculateRule. New service: services/fristenrechner_followups.go (404 LoC).
- 6 live-DB integration tests (services/fristenrechner_followups_test.go, 205 LoC): SoC follow-ups, party narrowing, jurisdiction filters, event_kind filters, unknown-event sentinel.

No schema changes — the unified sequencing_rules model already has every column needed.

Knuth proceeds to S2 (result view under ?overhaul=1).
2026-05-26 22:01:41 +02:00
mAi
7ea415145f feat(fristenrechner): Slice S1 — backend ?kind=events + /follow-ups (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Two additive endpoints behind the Fristenrechner overhaul (design
§6.1 + §6.2 in docs/design-fristenrechner-overhaul-2026-05-26.md):

1. GET /api/tools/fristenrechner/search?kind=events — returns
   procedural_events rows directly (not aggregated concept-cards),
   one hit per (event × proceeding_type) tuple. Trigram-ranked
   against name / name_en / code. Filters: jurisdiction, proc,
   event_kind, party. Powers Mode A's result list and Mode B's R4
   landing chips. Default search shape unchanged.

2. GET /api/tools/fristenrechner/follow-ups?event=...&trigger_date=...
   — given a trigger event (by code or uuid) + date, returns the
   immediate follow-up sequencing rules with computed due dates
   via litigationplanner.CalculateRule. Each row carries priority /
   primary_party / is_court_set / is_spawn / has_condition / legal
   source / spawn target so the result view can group into
   Mandatory / Recommended / Optional / Conditional with the
   SPAWNED badge. party=claimant|defendant filters keep "both"
   rules visible.

No schema changes — unified sequencing_rules already has every
column needed. Live-DB tests cover the SoC follow-up shape, party
narrowing, jurisdiction + event_kind filters, and the unknown-
event sentinel.
2026-05-26 22:01:10 +02:00
mAi
109946edff Merge: t-paliad-322 — Fristenrechner overhaul design doc (docs only) (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
553-line design doc documenting the complete Fristenrechner UX overhaul. Coder shift gated on m's go/no-go.

Two complementary entry paths into a shared result view:
- Mode A 'Direkt suchen' — search + filter chips (Forum · Proceeding · Event-Kind · Partei), result list of procedural_events, click locks a trigger.
- Mode B 'Geführt' — 3-5 row wizard (R1 event_kind → R2 forum → R3 proceeding_type → R4 procedural_event → R5 perspective), pre-filling + auto-skip from project context, row badges marking Filter vs Qualifier.

Shared result view groups follow-up sequencing_rules by Mandatory / Recommended / Optional / Conditional (SPAWNED folded with a 'neues Verfahren' badge). Trigger card sticks with inline-editable trigger date. Write-back via POST /api/projects/{id}/deadlines/bulk through a confirm-and-edit-dates modal. Kontextfrei mode hides the CTA entirely (m §11.Q7).

Filter vs Qualifier axis taxonomy ratified:
- forum, event_kind: filters
- proceeding_type, perspective (in file mode), procedural_event: qualifiers
- inbox channel: dropped from primary surface, kept as Mode A secondary chip

Backend deltas: extend /search to return events; new /follow-ups endpoint. No schema changes — the unified sequencing_rules model already has every column needed.

6-slice migration: S1 backend handlers → S2 result view (?overhaul=1) → S3 Mode A → S4 Mode B → S5 flip flag default → S6 drop buildRowStack + cascade reads. Procedure-mode (upper half of fristenrechner.tsx) untouched.

All 12 PRD questions ratified by m on 2026-05-26 via AskUserQuestion. 10/12 matched inventor recommendation; 2 diverged (Q3 section-split UX, Q7 hide kontextfrei CTA). Per-pick reasoning + design impact in §11.

Cronus parked on mai/cronus/inventor-fristenrechner. Coder shift held pending m's go.
2026-05-26 21:47:38 +02:00
mAi
528fe35540 design(fristen): fold m's 12 decisions into Fristenrechner overhaul doc
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
All 12 questions answered via AskUserQuestion. 10/12 = inventor recommendation.
2 diverged:

  Q3 (Filter-vs-qualifier UX): m picked section-split (Filter strip above,
      result/qualifier strip below) instead of '(Pflichtangabe)' tag.
      §3.1 Mode A layout rewritten with Filter strip header; §3.2 wizard
      rows now carry Filter/Qualifier badges next to the row number.

  Q7 (No-project mode): m picked 'Hide CTA entirely' instead of disabled-
      with-hint. §4.4 footer renders only when project != null; an inline
      'Tipp: Wähle oben eine Akte' nudge replaces the missing footer.

New §11 'm's decisions (2026-05-26)' anchors each pick with reasoning where
it diverges from the recommendation. §11.1 captures the two follow-on edits
to §3.1 and §4.4. Migration plan and backend contracts unchanged.

DESIGN READY FOR REVIEW pending head's coder gate.
2026-05-26 21:45:41 +02:00
mAi
9c2788ed8c design: Fristenrechner complete UX overhaul (t-paliad-322)
Inventor shift-1 design pass for m/paliad#146.

- Mode taxonomy (Direct-search A + Wizard B → shared result view)
- Filter-vs-qualifier table ratified (forum/event_kind/inbox as filters;
  proceeding_type/perspective as qualifiers)
- Wizard branching: R1 event_kind → R2 forum → R3 proceeding_type →
  R4 procedural_event → R5 perspective; rows prefill+collapse from project
- Result view: 4 priority groups (mandatory/recommended/optional/conditional)
  with SPAWNED folded into priority + cross-proceeding badge
- Project write-back via existing POST /api/projects/{id}/deadlines/bulk
  with confirm-and-edit-dates modal and audit_reason wording
- Backend deltas: extend /api/tools/fristenrechner/search to return
  procedural_events; new /api/tools/fristenrechner/follow-ups
- No schema changes — pure UX + handler shape
- 6-slice migration plan from current buildRowStack to overhaul under
  ?overhaul=1 flag, then flip + cleanup
- One worked example (LG Düsseldorf Hinweisbeschluss)
- 12 open questions for m (3 batches of 4 via AskUserQuestion)
2026-05-26 21:30:26 +02:00
mAi
c56859058d Merge: t-paliad-321 — mig 152 dedupe identical sequencing_rule clones + Proceeding column on admin list (m/paliad#144 follow-up)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
mig 151 archived 5 of 6 duplicate procedural_events for 'Mängelbeseitigung / Zahlung' and reparented their sequencing_rules. The 6 sequencing_rules themselves were byte-for-byte clones (NULL proceeding/rule_code, 14d duration) — admin showed 6 indistinguishable rows for one legal concept.

Mig 152: full-signature partition over sequencing_rules, lowest UUID per group as canonical, archive the rest. Audit-first RAISE NOTICE pre-block surfaces every clone-group in deploy logs. Snapshot to paliad.sequencing_rules_pre_152. Reparents deadlines.sequencing_rule_id (renamed from rule_id in mig 140). Defensive set_config('paliad.audit_reason') even though sequencing_rules has no audit trigger live.

Expected outcome: 5 archived (just Mängelbeseitigung / Zahlung). Other name-groups (Antrag auf Patentänderung×4, Beginn des Hauptsacheverfahrens×2, Berufungs*-R.220.1×2) have distinct (proceeding_type_id, rule_code, duration, primary_party) signatures — legitimately different rules per proceeding, left alone.

UI: admin-rules-list gains a Proceeding column (proceeding_type.code, server-side join). Replaces the legacy Verfahrenstyp column which was broken for non-fristenrechner categories. One column for proceeding info instead of two; works for every category.

Build + vet clean. NoDuplicateSlot passes.
2026-05-26 21:28:26 +02:00
20 changed files with 3722 additions and 16 deletions

View File

@@ -117,9 +117,13 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
return fmt.Errorf("mkdir output: %w", err)
}
// 1. Proceeding types — UPC + active only. The unified upc.apl row
// 1. Proceeding types — UPC primaries only. The unified upc.apl row
// from B1 mig 134 is included; the 3 archived old appeal codes
// (is_active=false) are filtered out by the WHERE.
// (is_active=false) are filtered out by the is_active predicate.
// The kind='proceeding' predicate (mig 153, t-paliad-325) belts the
// is_active filter so phase/side_action/meta rows can't slip into
// the embedded catalog even if some future deploy re-activates one
// for an admin task.
var procs []litigationplanner.ProceedingType
if err := pool.SelectContext(ctx, &procs, `
SELECT id, code, name, name_en, description, jurisdiction,
@@ -127,7 +131,9 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
trigger_event_label_de, trigger_event_label_en,
appeal_target
FROM paliad.proceeding_types
WHERE jurisdiction = 'UPC' AND is_active = true
WHERE jurisdiction = 'UPC'
AND is_active = true
AND kind = 'proceeding'
ORDER BY sort_order, id`); err != nil {
return fmt.Errorf("select proceeding_types: %w", err)
}

View File

@@ -0,0 +1,553 @@
# Design — Fristenrechner complete UX overhaul (dual-mode + project write-back)
**Task:** t-paliad-322
**Gitea:** m/paliad#146
**Inventor:** cronus (shift-1)
**Date:** 2026-05-26
**Status:** Draft for m's ratification — coder gate held
## 0. Premises verified live (before designing)
Verified against the live youpc Postgres (port 11833, paliad schema) and the live source tree on `mai/cronus/inventor-fristenrechner` @ HEAD.
### 0.1 Rule-and-event corpus today
| Table | Active+published rows | Notes |
|---|---|---|
| `paliad.procedural_events` | 222 (236 total) | The events that anchor a deadline. 4 `event_kind` buckets: `filing`, `hearing`, `decision`, `order`, plus `NULL` for legacy/dpma stragglers. |
| `paliad.sequencing_rules` | 231 | The deadlines themselves, anchored on `procedural_event_id` and (sometimes) `trigger_event_id`. 80 carry a `trigger_event_id`, 4 are `is_spawn=true`, 45 are `is_court_set=true`, 18 carry a `condition_expr`. |
| `paliad.deadline_concepts` | 57 | Hub layer above events (Klageerhebung, Wiedereinsetzung, …). |
| `paliad.proceeding_types` | 46 fristenrechner | 4 jurisdictions: UPC (35), DE (5), EPA (3), DPMA (3). |
| `paliad.event_categories` | 125 (103 leaves) | The current cascade tree — 5 user-bucket roots (`cms-eingang`, `muendl-verhandlung`, `beschluss-entscheidung`, `frist-verpasst`, `ich-moechte-einreichen`) + `sonstiges` leaf. UI hides the forward-workflow root (`HIDDEN_CASCADE_ROOTS` in `client/fristenrechner.ts:2605`). |
| `paliad.deadlines` | 10 (8 with `sequencing_rule_id`) | Demand-side still tiny. The 2 without `sequencing_rule_id` are manual entries. |
Live `primary_party` vocabulary on `sequencing_rules`: `claimant`, `defendant`, `both`, `court`, `NULL`. Live `priority` vocabulary: `mandatory`, `recommended`, `optional` (no `informational` rows yet — Phase 2 reserved the slot but seeding is deferred).
### 0.2 The legacy `deadline_rules` reader is a view
`paliad.deadline_rules_unified` (mig 139, Slice B.3) is a **view** over `sequencing_rules ⋈ procedural_events ⋈ legal_sources`. All Go calculator paths read through it (see `deadline_rule_service.go:70`). The physical `paliad.deadline_rules` table was dropped in mig 140; the view is the canonical legacy-shape reader. Important for this design: there is no "trigger event" table parallel to events — the rule rows themselves are the things the wizard must land on. `trigger_events` (110 rows) is the youpc-parity legacy table used by `/api/tools/event-deadlines` only.
### 0.3 The frontend today (`/tools/fristenrechner`)
Two server-rendered surfaces share the same page (`frontend/src/fristenrechner.tsx`, 657 lines) — the legacy "Procedure mode" (R1 step-list, proceeding picker, trigger date, flag checkboxes) and the **Pathway-B row stack** (`buildRowStack` at `client/fristenrechner.ts:2848`, 4009 lines total). Row stack composes three row kinds via a single `.fristen-row` primitive:
| Row | Source | Filter or qualifier today |
|---|---|---|
| R1 Perspective (Beide / Klägerseite / Beklagtenseite) | `currentPerspective`, prefilled from `project.our_side` | hybrid — narrows party-tagged cascade chips AND is used as a column-bucket hint in the result view |
| R2 Inbox channel (CMS / beA / Postal / Alle) | `currentInboxChannel` | filter — narrows cascade by forum (CMS → upc, beA → de, …) |
| R3..Rn Cascade chain | `event_categories` tree | each step narrows children by `inboxFilterAllowsForums` + `perspectiveAllowsParty` + `cascadeChildAllowsProject` |
The cascade auto-walks single-child branches under a project context and stops at the first branching point. The user picks a leaf; the leaf's slug feeds `/api/tools/fristenrechner/search?event_category_slug=…` which returns concept cards. Each card expands inline to a calc panel (trigger-date input + flags + computed deadline + "in Akte" CTA).
### 0.4 What is broken in this UI (m's verdict, 2026-05-26 21:21)
m's brief in m/paliad#146 enumerates four visible bugs:
1. **"Beide" as default perspective** is incoherent for the headline use case ("file a deadline because something happened" — you ARE one side).
2. **R2 (inbox) does not constrain R3 (cascade)** the way a user expects — picking CMS still leaves "Mündliche Verhandlung" / "Frist verpasst" on the next step. (Cause: those roots have `forums=NULL` in the seed → neutral → visible from every inbox.)
3. **Mixed axes** — the form layers filters (forum, inbox channel) on top of qualifiers (event-kind, perspective, proceeding_type) without making the difference visible. The user can't tell which picks narrow and which define.
4. **Trigger vs follow-up confusion** — the wizard's purpose is to identify the *trigger event*, then surface the *follow-up deadlines*. Today that split is not reflected in the form. After landing on a leaf, the user gets a flat list of concept cards and has to figure out which one is "the thing that happened" vs "the thing I have to file next".
m's verdict: "complete overhaul. Should be easy to use."
### 0.5 Anchor files for the eventual coder
- `frontend/src/client/fristenrechner.ts` (4009 LoC) — page brain. `buildRowStack` @ L2848, `renderRowStack` @ L3112, `runB1Search` (concept-card render) downstream, `expandCardCalc` @ L1337 (inline calc panel), `openSaveModal` @ L290 + `submitSave` @ L374 (project write-back).
- `frontend/src/fristenrechner.tsx` (657 LoC) — server-rendered shell. Contains both the Procedure-mode form **and** the Pathway-B row-stack scaffold. The new design replaces the row-stack scaffold; the procedure-mode form survives.
- `internal/handlers/fristenrechner.go` + `_search.go` + `_event_categories.go` — three handler files. `POST /api/tools/fristenrechner` (procedure-mode calc), `GET /search` (concept cards), `GET /event-categories` (cascade tree).
- `internal/services/fristenrechner.go` (661 LoC) — calculator adapter to `pkg/litigationplanner`. The calculator is **not** touched by this design.
- `internal/handlers/deadlines.go:167` + `services/deadline_service.go:411` (`CreateBulk`) — the project write-back endpoint (`POST /api/projects/{id}/deadlines/bulk`). This survives; the design extends its caller.
### 0.6 Adjacent design docs to read alongside
- `docs/design-determinator-row-cascade-2026-05-13.md` — the row-cascade pillars (Project-driven narrowing / Visual hierarchy overhaul / Persistent row stack). This overhaul **keeps** Pillars 2 and 3 and reworks Pillar 1's contract.
- `docs/design-fristen-phase2-2026-05-15.md` — the unified `sequencing_rules` model the calculator already runs on.
- `docs/audit-fristen-logic-2026-05-13.md` — the trigger-event / Pipeline-A-vs-C distinction.
---
## 1. Vision
**One page, two complementary entry paths, one result surface, one write-back.**
```text
┌───────────────────────── /tools/fristenrechner ─────────────────────────┐
│ │
│ ╭──────── Akte / kontextfrei ────────╮ (Step 0 — unchanged today) │
│ │ HL-2024-001 ▼ | ohne Akte │ │
│ ╰─────────────────────────────────────╯ │
│ │
│ ╭────── Entry mode tabs ──────╮ │
│ │ [⚡ Direkt suchen] │ ◀── A: power user, search + chips │
│ │ [🧭 Geführt] │ ◀── B: 3-5 question wizard │
│ ╰─────────────────────────────╯ │
│ │
│ ┌── Mode A: Suche ──────────────┐ ┌── Mode B: Wizard ────────────────┐│
│ │ search-box ▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢ │ │ R1 Was ist passiert? ✓ filing ││
│ │ filter chips: │ │ R2 Forum? ✓ UPC ││
│ │ Forum · Proceeding · Event- │ │ R3 Verfahren? ✓ INF ││
│ │ Kind · Partei │ │ R4 Welcher Schritt? [active] ││
│ │ ┌ Ergebnis-Liste ────────────┐│ │ R5 Welche Seite? ✓ Kläger ││
│ │ │ procedural_event hits ││ │ ││
│ │ │ [Trigger einrasten →] ││ │ (Direkt-suchen ←) ││
│ │ └────────────────────────────┘│ └───────────────────────────────────┘│
│ └────────────────────────────────┘ │
│ │
│ ════ shared from here ═══════════════════════════════════════════════ │
│ │
│ ┌── Trigger event (locked) ──────────────────────────────────────────┐ │
│ │ 📥 Klageschrift wurde eingereicht │ │
│ │ upc.inf.cfi · Verletzungsverfahren · Klägerseite │ │
│ │ Trigger-Datum: [📅 2026-05-20] (heute) │ │
│ │ ändern ↩ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌── Folge-Fristen ────────────────────────────────────────────────────┐ │
│ │ ◉ MANDATORY (auto-checked) │ │
│ │ ☑ Klageerwiderung (3 Monate) — 20.08.2026 — RoP 23 ✏ Datum │ │
│ │ ☑ ... │ │
│ │ ◇ OPTIONAL │ │
│ │ □ Wiedereinsetzungsantrag (R.320) — bei Versäumnis │ │
│ │ ◊ CONDITIONAL │ │
│ │ □ Erwiderung auf Nichtigkeitswiderklage nur wenn CCR │ │
│ │ ⇲ SPAWNED │ │
│ │ ☑ Berufung gegen Endurteil (kein Datum) │ │
│ │ ╭────────────────────────────╮ │ │
│ │ │ 4 ausgewählt → in Akte ▶ │ │ │
│ │ ╰────────────────────────────╯ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
The two modes never compete: they're two front doors into the **same** locked-trigger-event → follow-up-list → write-back flow.
---
## 2. Axis taxonomy — ratified (filters vs qualifiers)
The headline source of today's UX confusion is the unmarked mixing of *filters* (narrowing the question space without committing to an answer) and *qualifiers* (parts of the eventual deadline definition).
| Axis | Role | Source | Constrains | Visual in new UI |
|---|---|---|---|---|
| `forum` | **filter** | derived from `proceeding_types.jurisdiction` (UPC/DE/EPA/DPMA), or from `project.proceeding_type_id`, or user pick | which `proceeding_types` are reachable; which `event_categories` are visible | Mode A: filter chip strip. Mode B: explicit wizard row (R2). Pre-filled + collapsed when there's a project. |
| `proceeding_type` | **qualifier** | `project.proceeding_type_id` (binds via mig 096 codes) OR user pick during wizard | the set of `sequencing_rules` rows that can apply | Mode A: filter chip strip. Mode B: explicit wizard row (R3). Pre-filled + collapsed when there's a project. |
| `event_kind` | **filter** | `procedural_events.event_kind` (filing / hearing / decision / order) | which `procedural_events` are reachable as triggers | Mode A: filter chip strip. Mode B: explicit wizard row (R1 — the headline question). |
| `inbox channel` | **filter** (today) → **out of scope row** (new) | user pick | nothing the user can see (the rule corpus has no "inbox" column; it was only used to recolour the cascade) | Removed from the primary wizard. Pushed into Mode A's secondary chips (off by default). See §3.3. |
| `perspective (our_side)` | **qualifier in file-mode**, **filter in explore-mode** | `project.our_side` OR user pick OR implicit-via-event-kind | `sequencing_rules.primary_party`; result-view column bucketing | Wizard tail (R5) **only when** the trigger event's follow-ups actually differ by side. Pre-filled when project has `our_side`. |
| `instance_level` (first / appeal / cassation) | qualifier | `project.instance_level` (mig 084) — sparse | rare — used to disambiguate APP+DE | Surfaced only when the wizard hits APP+DE-style ambiguity. |
**Rule:** a **filter** narrows the visible options without locking in a deadline answer; it can be cleared and re-applied. A **qualifier** is part of the resulting deadline calculation and is locked the moment it's picked. Filters must propagate forward (Mode A's forum-chip narrows the proceeding-chip's options). Qualifiers are picked once and the answer view reads them.
The "Beide" perspective default (today's bug) is wrong because perspective is a *qualifier* in the headline use case ("file a deadline because something happened — you are one side"), not a *filter*. New default in Mode B: derive from the project's `our_side`, otherwise force a R5 pick (no "Beide"). See Q8 for the explore-mode exception.
---
## 3. Mode taxonomy
### 3.1 Mode A — "⚡ Direkt suchen" (power user)
Two visually distinct strips (per m §11.Q3):
```text
┌── Filter (eingrenzen) ─────────────────────────────────────────────────┐
│ Forum: [UPC] [DE] [EPA] [DPMA] [Alle] │
│ Verfahren: [upc.inf.cfi] [...] [Alle] │
│ Was passierte: [📥 Eingereicht] [🏛️ Termin] [⚖️ Entscheidung] [📜 Verfügung] [Alle] │
│ Partei: [Klägerseite] [Beklagtenseite] [Beide] │
├── Suchen ──────────────────────────────────────────────────────────────┤
│ 🔎 [_______________________________________________________________] │
└─────────────────────────────────────────────────────────────────────────┘
┌── Ergebnisse (klicken = als Trigger einrasten) ────────────────────────┐
│ 📥 Klageerhebung · upc.inf.cfi · UPC · 3 Folge-Fristen → │
│ ... │
└─────────────────────────────────────────────────────────────────────────┘
```
Single text input, four filter chip strips above it (Forum · Proceeding · Event-Kind · Partei), and a ranked result list of `procedural_events` underneath. The "Filter" strip is visibly grouped (e.g. light background + "Filter (eingrenzen)" header) so users see at a glance that those picks narrow but don't commit; clicking a result row IS the commit (the qualifier action).
- Search hits `/api/tools/fristenrechner/search` (extended to return events, not just concepts — see §6.1).
- Filter chips compose with the text query (`?forum=upc&pt=upc.inf.cfi&kind=filing&party=defendant&q=Klageerwiderung`).
- Result rows are individual `procedural_events` (not aggregated concept-cards). Each row shows: name (DE/EN), proceeding_type code, jurisdiction badge, event_kind icon, the rule-count it triggers ("3 Folge-Fristen").
- Click a row → "lock as trigger event" → page transitions to the §4 result view.
- Power affordance: a row with multiple linked rules can be locked in **per-rule** ("nur diese Frist") via a kebab menu on the row. (Sane default: lock the whole event; the kebab is for the lawyer who only wants one specific reactive deadline.)
### 3.2 Mode B — "🧭 Geführt" (the wizard)
A 3-5 question row stack that lands on one `procedural_events` row.
**Question order (strawman; m to ratify in Q5):**
1. **R1 — Was ist passiert?** Chips: 📥 Eingereicht (`filing`) · 🏛️ Termin (`hearing`) · ⚖️ Entscheidung (`decision`) · 📜 Verfügung (`order`) · 🕒 Frist versäumt (special bucket, routes to Wiedereinsetzung). One chip = one `event_kind` (or the special). Always asked. ~6 chips, fits one row.
2. **R2 — Vor welchem Gericht / bei welchem Amt?** Chips: UPC · LG/OLG/BGH · EPA · DPMA. Pre-filled from `project.proceeding_type → jurisdiction` (or `project.court` substring). **Skipped if R1 narrows to a single forum** (e.g. "Termin" + project has UPC → R2 is implied).
3. **R3 — In welchem Verfahren?** Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind. Pre-filled from `project.proceeding_type_id`. **Auto-skipped** when the narrowed scope has only one candidate.
4. **R4 — Welches Schriftstück / Welcher Termin?** This is the wizard's landing question. Chips = `procedural_events` filtered by (R2 forum, R3 proceeding_type, R1 event_kind). Typical scope: 1-12 events. If the user types into this row, the chip layout flips to a search list (same widget as Mode A's result list, narrowed to the wizard's filters).
5. **R5 — Vertreten Sie Kläger- oder Beklagtenseite?** Asked **only when** the selected event's `sequencing_rules` have follow-ups that differ by `primary_party` (a quick "are there both claimant- and defendant-tagged rules among the follow-ups?" check on the catalog). Pre-filled from `project.our_side`. **Skipped otherwise.**
**Row badges** (per m §11.Q3): each wizard row carries a small "Filter" or "Qualifier" tag next to its row-number badge. R1 (event_kind), R2 (forum) → "Filter". R3 (proceeding_type), R4 (procedural_event), R5 (perspective) → "Qualifier". A user can tell at a glance which picks lock in vs which narrow.
Branching policy (locked):
- Pre-fill + collapse a row when the answer is implied by the project (Determinator §4 pattern, unchanged).
- Auto-skip a row when the narrowed scope has exactly one option (the user has effectively no choice). Show the skipped row as a compact `.fristen-row.is-prefilled` line with "(aus Akte)" or "(implizit aus R1)" annotation. *Don't hide the row* — m's "see your selections" pillar from the row-cascade design demands every decision stays visible.
- A user-edited upstream answer **preserves compatible downstream picks** (m §11.Q10): if a re-picked R2 (forum) keeps the existing R1 (event_kind) legal, R1 stays; if it makes R3 (proceeding_type) illegal, R3 resets to active. Rows whose pick was carried across an upstream change render with a one-render "erhalten" annotation so the user notices.
- "Welches Schriftstück?" (R4) is the landing question. Once R4 is answered, the wizard exits and the §4 result view takes over.
### 3.3 The dropped `inbox channel` row
R2-inbox in today's row stack is removed from the primary surface for both modes. Rationale:
- The rule corpus has no `inbox` column. The cascade's `forums=['cms']` etc. tags were a presentation-layer reflection of which forum naturally arrives on which channel — but the rule itself doesn't change based on whether a UPC document arrived via CMS or by post (it can't; only CMS is legal). So the only honest role for "inbox" is to nudge the forum filter on Mode A.
- Mode A keeps inbox as a *secondary* chip strip ("Erweitert" toggle, off by default). Picking CMS auto-sets the forum chip to UPC; picking beA auto-sets it to DE. The user can override.
- Mode B never asks. The wizard derives forum from project context or from R2.
This collapses one bug class entirely (R2-not-constraining-R3) by retiring R2 from the headline path.
---
## 4. Shared result view — "follow-up deadlines"
Once a trigger event is locked (via Mode A click or Mode B R4 pick), the same result view renders.
### 4.1 Trigger card (sticky header)
```text
┌─ Trigger-Ereignis ─────────────────────────────────────────────────────┐
│ 📥 Klageerhebung │
│ upc.inf.cfi · Verletzungsverfahren · UPC │
│ ⓘ "Einreichung der Klageschrift gemäß R.13 RoP" │
│ Trigger-Datum: 📅 2026-05-20 [ändern ↩] │
└─────────────────────────────────────────────────────────────────────────┘
```
`Trigger-Datum` defaults to today. The user can change it inline (date picker). Changing it re-renders the follow-ups with new computed dates.
The "ändern" link drops back to whichever mode the user came from with R1-R4 still answered. (Per Q4: the wizard preserves compatible upstream picks rather than rebooting.)
### 4.2 Follow-up groups
Group `sequencing_rules` rows that have the trigger event as **anchor** (i.e. `sr.procedural_event_id = trigger.id`) into 4 visible groups:
1. **MANDATORY** (`priority='mandatory'`) — pre-checked. The bread-and-butter follow-ups.
2. **RECOMMENDED** (`priority='recommended'`) — pre-checked. Best-practice fillings (R.19 EPÜ Einspruch, replication briefs).
3. **OPTIONAL** (`priority='optional'`) — unchecked. Discretionary actions (R.320 Wiedereinsetzung).
4. **CONDITIONAL** (`condition_expr IS NOT NULL`) — unchecked, with the condition rendered ("nur wenn CCR im Verfahren"). Lawyer ticks if applicable.
Plus a fifth implicit bucket:
5. **SPAWNED / CROSS-PROCEEDING** (`is_spawn=true`, `spawn_proceeding_type_id IS NOT NULL`) — surfaced as a separate sub-section with a clear "leitet ein neues Verfahren ein" annotation. Pre-checked when mandatory.
Recommendation (Q6): **4 visible groups, with SPAWNED inlined into whichever priority bucket it belongs to but tagged with a "⇲ neues Verfahren" badge.** Five groups is too many for a one-page result; folding SPAWNED into its priority keeps the math right (mandatory spawned = mandatory) while still flagging the cross-proceeding implication.
### 4.3 Per-rule row
```text
☑ Klageerwiderung ✏ Datum
3 Monate nach Klageerhebung 20.08.2026
RoP 23 · Beklagtenseite
ⓘ Schriftlich, mit Vollmacht. Erstmaliges Bestreiten der Patentverletzung.
```
Columns: checkbox · title (DE/EN) · duration phrase · computed due date · rule citation · party stance · expandable notes.
Inline date editor (✏ Datum) lets the lawyer override the computed date for this rule (same affordance as today's `wireDateEditClicks`). The override flows into the write-back payload.
`is_court_set=true` rules render with the "wird vom Gericht bestimmt" placeholder instead of a date and are unchecked-by-default (matches the current `openSaveModal` behaviour).
### 4.4 Result-view footer (write-back CTA)
```text
┌─ Auswahl ──────────────────────────────────────────────────────────────┐
│ 4 Fristen ausgewählt → In Akte HL-2024-001 eintragen ▶ │
│ (oder: 2 mit eigenem Datum, 2 mit Standardberechnung) │
└─────────────────────────────────────────────────────────────────────────┘
```
The CTA opens a **confirm-and-edit-dates modal** (per m §11.Q6) where the lawyer can revise each selected deadline's due date one last time, then commits via `POST /api/projects/{id}/deadlines/bulk` (today's endpoint).
**Kontextfrei mode (no Akte)** — per m §11.Q7, the entire write-back footer **does not render** when `project == null`. The result view stays informational. In its place, an inline nudge appears above the deadline groups:
```text
ⓘ Tipp: Wähle oben eine Akte, um diese Fristen einzutragen.
```
The "oben" link focuses the Akte picker. Once a project is picked, the nudge collapses and the footer materialises; no page reload, no result-view rebuild (the trigger event and date persist across the project pick).
Modal payload per deadline (extends today's `CreateDeadlineInput`):
```json
{
"title": "Klageerwiderung",
"rule_code": "RoP 23",
"due_date": "2026-08-20",
"original_due_date": "2026-08-20",
"source": "fristenrechner",
"rule_id": "<sequencing_rules.id>", /* maps to deadlines.sequencing_rule_id */
"notes": "..."
}
```
**audit_reason wording (per Q12):** every row inserted via this flow carries an audit-log breadcrumb on the project (matches the deadline `Verlauf` pattern). Default reason string:
> `Aus Fristenrechner — Trigger: {trigger_event_name} ({trigger_date_iso})`
e.g. `Aus Fristenrechner — Trigger: Klageerhebung (2026-05-20)`. Falls into `paliad.project_events` with `kind='deadline_created'` via the existing `DeadlineService.CreateBulk` audit hook; no schema change needed.
---
## 5. URL / state representation
The new flow keeps Pathway-B's URL-as-state contract, simplified:
| Param | Owner | Meaning |
|---|---|---|
| `project` | Step 0 | Active project UUID. Drives the prefills. |
| `mode` | mode tab | `wizard` (default) or `search`. |
| `q` | Mode A | Free text query. |
| `forum` | Mode A | Comma-separated forum codes (`upc,de`). Mode B writes this only when the wizard derives it. |
| `pt` | Mode A | Selected proceeding_type code. |
| `kind` | Mode A | event_kind chip pick. |
| `party` | both | Perspective. Mode A's chip; Mode B's R5. |
| `wizard` | Mode B | Dotted state cursor encoding which row is active and the picks made: `wizard=kind:filing,forum:upc,pt:upc.inf.cfi,active:event`. |
| `event` | both | The locked trigger `procedural_events.code`. Once set, the result view renders. |
| `trigger_date` | result | ISO date. Default = today; URL only carries it when overridden. |
| `selected` | result | Comma-separated `sequencing_rules.id` checkbox state. Only carried when it differs from the priority default. |
Deep links work end-to-end: `?project=…&event=upc.inf.cfi.soc&trigger_date=2026-05-20&selected=…` jumps a colleague straight to the result view with the same picks. (Per Q11 — query string, not pathname.)
`popstate` rebuilds the page from the params alone (same pattern as today). The wizard state cursor lets browser back/forward step the wizard rows instead of dropping back to the page root.
---
## 6. Backend contract changes
### 6.1 Extend `/api/tools/fristenrechner/search`
Today returns concept-cards. Add an alternate response shape (or a `?kind=events` flag) that returns `procedural_events` rows directly:
```json
{
"query": "Klageerhebung",
"filters": { "forum": "upc", "pt": null, "kind": "filing", "party": null },
"events": [
{
"id": "<uuid>",
"code": "upc.inf.cfi.soc",
"name_de": "Klageerhebung",
"name_en": "Statement of Claim",
"event_kind": "filing",
"proceeding_type": { "code": "upc.inf.cfi", "jurisdiction": "UPC", "name": "..." },
"follow_up_count": 3,
"concept_id": "<uuid>",
"score": 0.92
}
],
"total": 12
}
```
The concept-card shape stays available for the legacy Pathway-B-filter route (kept as a deep-link compat surface, not user-facing).
### 6.2 New `/api/tools/fristenrechner/follow-ups`
Given a trigger event id + trigger date + optional party qualifier, return the follow-up `sequencing_rules` rows, grouped + with computed dates. Wire shape:
```json
{
"trigger": { "id": "...", "code": "upc.inf.cfi.soc", "name_de": "Klageerhebung", "event_kind": "filing", "proceeding_type": { "code": "upc.inf.cfi", "name_de": "Verletzungsverfahren", "jurisdiction": "UPC" } },
"trigger_date": "2026-05-20",
"party": "claimant",
"follow_ups": [
{
"rule_id": "<uuid>",
"title_de": "Klageerwiderung",
"title_en": "Defence",
"priority": "mandatory",
"primary_party": "defendant",
"duration_phrase": "3 Monate",
"due_date": "2026-08-20",
"is_court_set": false,
"is_spawn": false,
"condition_expr": null,
"rule_code": "RoP 23",
"notes_de": "...",
"spawn_label": null,
"spawn_proceeding_type": null,
"appeal_target": null
}
]
}
```
Implementation: `FristenrechnerService.LookupFollowUps(ctx, eventID, triggerDate, party)` — wraps `catalog.LookupEvents(axes={EventID:…, Depth:Next})` (already implemented for the Litigation Planner per `services/fristenrechner.go:251`) and runs the result through `pkg/litigationplanner.Calculate` to fill the dates. The calculator is unchanged.
### 6.3 No schema changes
This design is pure UX + handler shape. The unified `sequencing_rules` model already has every column needed (priority, condition_expr, spawn_*, is_court_set, primary_party, applies_to_target). No migration accompanies this design.
---
## 7. Migration plan — from current row stack to the overhaul
Drop nothing on day one; co-exist for one release. The cutover is by URL flag.
| Phase | What changes | What survives | Branch |
|---|---|---|---|
| **S1 — Backend** | Add `GET /search?kind=events`. Add `GET /follow-ups`. Both feature-flagged behind a request header (off by default). | Existing endpoints. | one PR |
| **S2 — Result view** | New `frontend/src/client/fristenrechner-result.ts` module — given a trigger event + date, render the §4 result view. Mount under a `?overhaul=1` query flag on /tools/fristenrechner. The legacy `renderProcedureResults` stays. | All today's UI. | one PR |
| **S3 — Mode A** | New search-with-filter-chips UI. Mount alongside the row stack under `?overhaul=1`. | Row stack still primary. | one PR |
| **S4 — Mode B (wizard)** | New `frontend/src/client/fristenrechner-wizard.ts` — the 3-5 row stack. Replaces today's `buildRowStack` only when `?overhaul=1`. Project prefill logic from `buildRowStack` ports 1:1. | The legacy row stack stays in place under no flag. | one PR |
| **S5 — Flip the flag** | `?overhaul=1` becomes the default. Legacy row stack and `event_categories`-based cascade rendered with a hard-coded `?legacy=1` for two weeks. | Procedure mode (the upper half of `fristenrechner.tsx`) is unchanged throughout. | one PR |
| **S6 — Cleanup** | Drop the `buildRowStack` function tree and the `event_categories`-served cascade endpoint (the table can stay — it's still semantically a useful taxonomy for future tools, just not the Fristenrechner's UI). Drop the `HIDDEN_CASCADE_ROOTS` constant and the cascade-segment bridge. | None of today's row-stack code. | one PR |
Single project per slice; each PR rebases off main; no shared branches.
The `event_categories` table itself **stays**`audit-fristen-logic-2026-05-13.md` §2.4 already calls it "a config layer" useful for taxonomy work. The Fristenrechner just no longer reads it. Future tools (the "Ich möchte einreichen" forward-workflow surface m hid in `HIDDEN_CASCADE_ROOTS`) can resurrect it without DB migration.
---
## 8. Worked example — "PA at LG Düsseldorf bekommt einen Hinweisbeschluss via CMS in einer aktiven Akte"
Project: `HL-2024-001`, proceeding_type=`de.inf.lg` (Verletzungsverfahren LG), `our_side='defendant'`, `court='LG Düsseldorf'`.
### 8.1 Wizard path (Mode B, default)
User opens /tools/fristenrechner with that project in Step 0. Mode tab defaults to "🧭 Geführt".
Wizard rows render top-to-bottom, pre-filled where the project implies:
```text
[1] Was ist passiert? [ active — chips for filing/hearing/decision/order/missed ]
[2] Vor welchem Gericht? ✓ LG (aus Akte: HL-2024-001) ← prefilled+collapsed
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled+collapsed
```
User clicks ⚖️ Entscheidung in R1.
Row stack updates:
```text
[1] Was ist passiert? ✓ Entscheidung ← answered
[2] Vor welchem Gericht? ✓ LG (aus Akte) ← prefilled
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled
[4] Welche Entscheidung konkret? [ active — chips: Urteil, Beschluss, Hinweisbeschluss, ... ]
```
R4 chip set is the `procedural_events` whose `proceeding_type_id` = de.inf.lg AND `event_kind` = 'decision'. (Hinweisbeschluss is in this set — `de.inf.lg.hinweisbeschluss` or similar.)
User clicks Hinweisbeschluss. The wizard checks: do the follow-up rules differ by `primary_party`? In this case yes (the Hinweis triggers a reply window for the defendant only). So R5 fires:
```text
[5] Welche Seite vertreten Sie? ✓ Beklagtenseite (aus Akte) ← prefilled
```
R5 is pre-filled from `project.our_side='defendant'`. The user could click ändern to override, but doesn't.
Wizard transitions to the §4 result view. Trigger card: "📜 Hinweisbeschluss · de.inf.lg · LG · Beklagtenseite". Trigger date defaults to today.
### 8.2 Result view
Three follow-ups in scope (illustrative):
```text
MANDATORY
☑ Stellungnahme zum Hinweisbeschluss (Frist 4 Wochen) — 24.06.2026 — ZPO §139
RECOMMENDED
☑ Anpassung der Klageerwiderung — 24.06.2026 — best practice
OPTIONAL
□ Antrag auf Fristverlängerung (begründet) — auf Antrag
```
User unchecks "Anpassung", changes the Stellungnahme date inline to 2026-06-20 (one weekday earlier), clicks "In Akte HL-2024-001 eintragen ▶".
Modal opens with the 1 selected deadline + the user's date override. User confirms.
### 8.3 Write-back
Server-side: `POST /api/projects/HL-2024-001/deadlines/bulk` with one `CreateDeadlineInput`:
```json
{
"title": "Stellungnahme zum Hinweisbeschluss",
"rule_code": "ZPO §139",
"due_date": "2026-06-20",
"original_due_date": "2026-06-24",
"source": "fristenrechner",
"rule_id": "<sr-uuid>",
"notes": null
}
```
`DeadlineService.CreateBulk` inserts the row into `paliad.deadlines` (with `sequencing_rule_id` populated from `rule_id`), creates the audit event with the wording "Aus Fristenrechner — Trigger: Hinweisbeschluss (2026-05-26)", and the user is redirected to `/deadlines?project_id=…` with a green success toast.
### 8.4 Mode A path for the same user
User flips the mode tab to "⚡ Direkt suchen". Filter chips auto-load to Forum=DE + Proceeding=de.inf.lg (from project context). User types "Hinweis" — the result list shows `de.inf.lg.hinweisbeschluss` (and maybe `upc.inf.cfi.hinweis` filtered out because Forum=DE narrows it). User clicks. Same result view appears.
Total clicks Mode A: 2 (type + click). Mode B: 2 (R1 chip + R4 chip; R2/R3/R5 prefilled). The wizard wins for trainees who don't know vocabulary; search wins for power users who know "Hinweisbeschluss" and can type 4 chars.
---
## 9. What's NOT in scope
- **Replacing the `sequencing_rules` model.** Phase 3 schema is already what the calculator runs on.
- **Paliadin (LLM) integration into the wizard.** A "Frist-Extraktion aus Dokument" path is filed elsewhere (memory `b6a11b55…`) and stays out of this design. The wizard could later call out to Paliadin for "the user typed something we don't know" — Phase 2 of *this* overhaul, not Phase 1.
- **Calendar / Outlook sync** of created deadlines. Separate t-paliad ticket per project-status.md long-term goals.
- **Editing `sequencing_rules`** from the result view. Read-only here. The admin surface at `/admin/procedural-events` handles editing.
- **The Procedure-mode surface** (upper half of `fristenrechner.tsx`). The proceeding-picker + trigger-date + flag-checkbox UI stays exactly as it is today. That surface answers a different question ("show me the full procedural ablauf for upc.inf.cfi") and is the right tool for that question; the overhaul targets only the Pathway-B / row-stack half of the page.
---
## 10. Open questions for m (12 questions, batched for `AskUserQuestion`)
All 12 questions tracked in m/paliad#146 § "Open design questions". Each gets a recommended option (listed first in the AskUserQuestion call). Bundled into 3 batches of 4.
| # | Topic | Recommended pick |
|---|---|---|
| Q1 | Single page or stepper? | Single page with mode-tabs + collapsible rows. |
| Q2 | Mode switcher placement | Tab pair under Step-0 ("Akte / kontextfrei"). |
| Q3 | Filter-vs-qualifier UX | Qualifiers carry a small "(Pflichtangabe)" tag; filters render in a slimmer pill. |
| Q4 | Cascade tree (keep/replace) | Replace with the 5-question wizard. Drop `event_categories` from the Fristenrechner UI (table stays). |
| Q5 | Result grouping | 4 visible groups (Mandatory / Recommended / Optional / Conditional), SPAWNED folded with badge. |
| Q6 | Project write-back UX | Confirm-and-edit-dates modal (revise each date once before commit). |
| Q7 | No-project mode | CTA disabled with hint ("Wähle eine Akte oben"). Match today's pattern. |
| Q8 | Perspective semantics by mode | Mode B (file): qualifier — required pick. Mode A (search): filter — optional. |
| Q9 | Trigger-date input timing | In the result-view trigger card; default today; inline editable. |
| Q10 | Backward navigation | Preserve compatible downstream picks; reset only those invalidated. |
| Q11 | Deep-link encoding | Query string (`?event=…&trigger_date=…`). |
| Q12 | Audit reason wording | `Aus Fristenrechner — Trigger: {name} ({date})`. |
(Recommendations land as the "first option" in each AskUserQuestion call per the inventor SKILL contract.)
---
## 11. m's decisions (2026-05-26)
All 12 questions answered via `AskUserQuestion` on 2026-05-26 21:30. Recording each pick + the reasoning where it diverges from the inventor's recommendation. Sections of the design that are now load-bearing on these answers carry a "(m §11.Q{n})" cross-reference.
- **Q1 (Page layout): Single page, mode-tabs.** [= recommendation] Both modes share /tools/fristenrechner; the mode-tabs swap the question surface in place. Result view is shared. **Locks §3, §4, §5.**
- **Q2 (Mode switcher): Tab pair under Step-0.** [= recommendation] "⚡ Direkt suchen" / "🧭 Geführt" tabs render directly below the Akte picker. Project context survives the tab flip; compatible filter picks (forum, proceeding) carry across.
- **Q3 (Filter-vs-qualifier UX): Section split — Filter above, Qualifier below.** [≠ recommendation; m picked option 2.] Mode A's filter chips render in a "Filter (eingrenzen)" strip on top; below it, the result list is the qualifier surface (clicking a row locks). Mode B wizard rows carry a small "Filter" / "Qualifier" badge in the row badge area (e.g. R1/R2 = Filter, R3/R5 = Qualifier). The "(Pflichtangabe)" tag from the original recommendation is replaced by this section-level visual hierarchy. **Updates §3.1 (Mode A layout) and §3.2 (wizard row badges).**
- **Q4 (Cascade tree): Replace with wizard, keep table.** [= recommendation] The Fristenrechner UI stops reading `paliad.event_categories`. The table stays for future tools (the hidden "Ich möchte einreichen" forward-workflow). **Locks §3.2 and the cleanup in §7 S6.**
- **Q5 (Result grouping): 4 groups + SPAWNED badge.** [= recommendation] Mandatory / Recommended / Optional / Conditional are the four sub-sections; spawned rules fold into their priority bucket with a `⇲ neues Verfahren` badge. **Locks §4.2.**
- **Q6 (Write-back UX): Confirm-and-edit-dates modal.** [= recommendation] Inline checkbox selection in the result view → "In Akte eintragen ▶" → modal with editable due-date fields per row + Akte picker. **Locks §4.4.**
- **Q7 (No-project mode): Hide the CTA entirely.** [≠ recommendation; m picked option 3.] In kontextfrei mode the result view renders without the write-back footer at all — no disabled-with-hint button. Rationale (inferred from m's pick): the result view is informational by design in explore mode, and a permanently-disabled CTA is visual noise. **Updates §4.4** — the CTA is conditional on `project != null`, not on `disabled`. The hint message moves into the Step-0 picker: when a user is in kontextfrei mode and reaches a result view, a one-line nudge appears above the result groups ("Tipp: Wähle oben eine Akte, um diese Fristen einzutragen") with a link to focus the Akte picker. This preserves the affordance discovery without polluting the footer.
- **Q8 (Perspective semantics): Mode B qualifier, Mode A filter.** [= recommendation] Wizard Mode B's R5 is required and Klagerseite/Beklagtenseite only (no "Beide"); Mode A's perspective chip is a filter with a "Beide" option, off by default. **Locks §2 axis table and §3.2 R5 description.**
- **Q9 (Trigger-date input): In the result-view trigger card.** [= recommendation] The sticky header card on the result view shows the date; default today; inline editable. Changing it re-renders follow-up dates live. **Locks §4.1.**
- **Q10 (Backward navigation): Preserve compatible picks.** [= recommendation] Re-opening any wizard row keeps downstream picks that are still legal under the new upstream value; resets only the picks the new value invalidates. A small chip-strip annotation ("erhalten") appears for one render-cycle on rows whose pick was carried so the user notices. **Updates §3.2 branching policy.**
- **Q11 (Deep-link encoding): Query string.** [= recommendation] `?project=…&mode=…&event=…&trigger_date=…&selected=…&forum=…&pt=…&kind=…&party=…` — every state piece is a query param. `popstate` rebuilds the page from params. **Locks §5.**
- **Q12 (Audit reason wording): `Aus Fristenrechner — Trigger: {name} ({date})`.** [= recommendation] German-locale, includes the trigger event name and its ISO date. Stored as `paliad.project_events.metadata->>'audit_reason'` via the existing `DeadlineService.CreateBulk` audit hook. **Locks §4.4.**
### 11.1 What changed from the strawman as a result
Two follow-on edits flow from m's picks:
1. **§3.1 Mode A layout** — top strip is "Filter (eingrenzen)" with the four filter chip groups (Forum · Proceeding · Event-Kind · Partei); the result list directly below carries the implicit "click here to lock" qualifier action. No "(Pflichtangabe)" tag.
2. **§4.4 Write-back footer** — the footer is rendered conditionally on `project != null`. The kontextfrei-mode informational nudge moves into the result view body above the deadline groups.
These edits don't change the §7 migration plan or the §6 backend contracts.
---
## 12. Synthesis links
- mBrian topic: `topic-fristenrechner` (existing) — file this design as a `[synthesis]` node linked `triggered_by` t-paliad-322 and `related_to` the row-cascade + Phase 2 designs.
- Related memories: row-cascade design `0fbd2c1a-…`, Phase 2 design `a454dc86-…`, audit logic `f6c0c3a2-…`.

View File

@@ -0,0 +1,580 @@
# Design — `paliad.proceeding_types` taxonomy cleanup: primary proceedings vs phases vs side-actions vs meta
**Task:** t-paliad-324
**Gitea:** m/paliad#147
**Inventor:** atlas (shift-1)
**Date:** 2026-05-26
**Status:** Draft — coder gate held until m ratifies the 10 design questions in §9
**Branch:** `mai/atlas/inventor-proceeding`
---
## 0. Premises verified live (before designing)
Verified against live youpc Postgres (port 11833, `paliad` schema) on 2026-05-26 22:05. Findings supersede the audit grouping in m/paliad#147 wherever they diverge — the issue body was correct on shape but conservative on counts.
### 0.1 The 46-row table, fully classified by usage
`paliad.proceeding_types` has 49 rows total; 46 active, 3 inactive (`upc.apl.merits/cost/order` — superseded by `upc.apl.unified`, id 160) plus 1 archive bucket (`_archived_litigation`, id 32). Cross-references against the four downstream consumers:
| Consumer | Column | Active rows that point at the 46 active types |
|---|---|---|
| `paliad.sequencing_rules.proceeding_type_id` | rule's anchor proceeding | **18 distinct rows used** — the primaries with corpus. 28 rows have 0 rules. |
| `paliad.sequencing_rules.spawn_proceeding_type_id` | cross-proceeding spawn target | **1 distinct row used**`upc.apl.merits` (id=11, **inactive!**). 0 active types are spawn targets. |
| `paliad.projects.proceeding_type_id` | project's primary type | **6 distinct rows used** (across 18 projects). All 6 are in the 18 primaries. |
| `paliad.event_category_concepts.proceeding_type_code` | concept's owning proceeding | **18 distinct codes used.** 3 of those codes (`upc.apl.merits`, `upc.apl.order`, `upc.apl.cost`) point at **inactive** rows — pre-existing data drift from the `upc.apl.unified` merger (flagged §8, out of scope here). |
The audit answer in one sentence: **of the 46 active rows, only 18 have any downstream consumer pointing at them today** (the 18 primaries with corpus). The remaining 28 rows are decorative — they exist in the table but nothing references them.
This makes reparenting **trivially safe**: no FK invariant breaks, no SQL update touches existing data, no migration risk.
### 0.2 The 18 primaries with corpus (rules + concepts)
Ordered by `paliad.sequencing_rules` count (descending), with `event_category_concepts` count alongside:
| id | code | jurisdiction | rules | concepts | projects |
|---:|---|---|---:|---:|---:|
| 8 | `upc.inf.cfi` | UPC | 25 | 14 | 1 |
| 9 | `upc.rev.cfi` | UPC | 17 | 10 | 0 |
| 160 | `upc.apl.unified` | UPC | 16 | 0 *(see drift note)* | 0 |
| 12 | `de.inf.lg` | DE | 11 | 4 | 1 |
| 13 | `de.null.bpatg` | DE | 10 | 4 | 1 |
| 14 | `epa.opp.opd` | EPA | 8 | 7 | 1 |
| 15 | `epa.opp.boa` | EPA | 8 | 12 | 0 |
| 16 | `epa.grant.exa` | EPA | 8 | 0 | 0 |
| 17 | `upc.dmgs.cfi` | UPC | 8 | 1 | 0 |
| 26 | `de.inf.bgh` | DE | 8 | 17 | 0 |
| 25 | `de.inf.olg` | DE | 7 | 8 | 0 |
| 10 | `upc.pi.cfi` | UPC | 7 | 3 | 0 |
| 27 | `de.null.bgh` | DE | 6 | 10 | 0 |
| 29 | `dpma.appeal.bpatg` | DPMA | 5 | 6 | 0 |
| 30 | `dpma.appeal.bgh` | DPMA | 4 | 8 | 0 |
| 28 | `dpma.opp.dpma` | DPMA | 4 | 3 | 1 |
| 18 | `upc.disc.cfi` | UPC | 4 | 1 | 0 |
| 35 | `upc.ccr.cfi` | UPC | 1 | 0 | 1 |
These 18 are unambiguously **primary proceedings** in the m/paliad#147 sense — self-contained matters, own filing, own deadline cascade, own ablauf. They survive every model.
### 0.3 The 4 unloaded primaries (Group A continued)
Four more active rows are conceptually primaries but carry **zero rules and zero concepts today** — seeded for catalog completeness, waiting for corpus:
| id | code | jurisdiction | what it is |
|---:|---|---|---|
| 171 | `upc.dni.cfi` | UPC | Negative Feststellungsklage — standalone declaratory action |
| 172 | `upc.epo.review` | UPC | Überprüfung von EPA-Entscheidungen — standalone review action |
| 179 | `upc.bsv.cfi` | UPC | Beweissicherung / saisie — standalone evidence-preservation order |
| 188 | `upc.pl.cfi` | UPC | Schutzschrift — pre-litigation defensive filing |
These are **primary** by character (each has its own RoP-defined filing pathway and its own deadline tree once rules get seeded) but **unloaded** today. Decision: keep them as `kind='proceeding'` so Mode B R3 surfaces them for future rule attachment and `pkg/litigationplanner` accepts them as valid catalog codes.
§9 Q3.b discusses `upc.pl.cfi` (it's the only borderline — Schutzschrift is technically a pre-action filing, not a proceeding at the time of filing). m's call.
### 0.4 The 28 non-primary rows
The 28 active rows that have **zero rules + zero concepts + zero projects pointing at them** group cleanly into three categories:
#### Group B — Phases of a primary CFI proceeding (5 rows)
These describe stages *within* an existing CFI proceeding, not standalone matters. A `upc.inf.cfi` action passes through interim → oral → decision phases; the phase isn't a separately-elected proceeding type.
| id | code | name |
|---:|---|---|
| 173 | `upc.cfi.interim` | CFI - Zwischenverfahren |
| 174 | `upc.cfi.oral` | CFI - Mündliche Verhandlung |
| 175 | `upc.cfi.decision` | CFI - Endentscheidung |
| 176 | `upc.costs.cfi` | Separate Kostenentscheidung *(post-decision sub-phase)* |
| 185 | `upc.default.cfi` | Versäumnisentscheidung *(alt. decision outcome)* |
The "phase" concept already has a natural home in the data model: `paliad.procedural_events.event_kind` (filing/hearing/decision/order). What `upc.cfi.interim` actually represents is "all events with kind=filing under upc.inf.cfi/upc.rev.cfi/upc.pi.cfi/etc."; `upc.cfi.oral` is "all events with kind=hearing"; `upc.cfi.decision` is "all events with kind=decision". The proceeding-type row buys nothing the event_kind already carries.
#### Group C — Side-actions inside a proceeding (10 rows)
Applications and court orders that arise *inside* a primary proceeding. They could each become a `condition_expr`-gated rule on the parent proceeding when corpus arrives; they don't need their own proceeding row.
| id | code | name |
|---:|---|---|
| 178 | `upc.evidence.cfi` | Beweisanordnungen (allgemein) |
| 182 | `upc.experiments.cfi` | Gerichtlich angeordnete Versuche |
| 177 | `upc.security.cfi` | Sicherheitsleistung |
| 184 | `upc.intervention.rop` | Streitbeitritt |
| 165 | `upc.parties.change` | Parteiwechsel / Patentübergang |
| 170 | `upc.optout.cfi` | Antrag auf Opt-out |
| 180 | `upc.inspection.cfi` | Besichtigungsantrag |
| 181 | `upc.freezing.cfi` | Anordnung zur Vermögenssperre |
| 187 | `upc.withdrawal.rop` | Klagerücknahme |
| 183 | `upc.rehearing.coa` | Wiederaufnahmeantrag |
A subtle distinction: `upc.bsv.cfi` (Beweissicherung) IS a standalone primary (its own RoP filing) whereas `upc.evidence.cfi` (Beweisanordnungen allgemein) is a side-action class (orders the court makes inside any proceeding). The two are not duplicates; the categorisation is structural, not nominal.
#### Group D — Cross-cutting administrative / meta (8 rows)
These describe rules-of-procedure mechanics, not matters a lawyer takes on. None of them is a "Verfahren" in any user-facing sense.
| id | code | name |
|---:|---|---|
| 162 | `upc.case.mgmt` | Verfahrensverwaltung |
| 161 | `upc.general.rop` | Allgemeine Bestimmungen |
| 163 | `upc.service.rop` | Zustellung von Schriftsätzen |
| 168 | `upc.language.rop` | Verfahrenssprache |
| 164 | `upc.representation.rop` | Vertretung / Anwaltsprivileg |
| 166 | `upc.fees.court` | Gerichtsgebühren |
| 167 | `upc.legalaid.cfi` | Prozesskostenhilfe |
| 186 | `upc.special.cfi` | Besondere Verfahrenslagen |
| 169 | `upc.reestablishment.rop` | Wiedereinsetzung in den vorigen Stand *(cross-cutting; applies to every proceeding)* |
`upc.reestablishment.rop` lands in Group D because **every** proceeding has a Wiedereinsetzung path — it isn't a kind-of-proceeding, it's a cross-cutting remedy. Today's rules already model it correctly (it's a `condition_expr`-gated rule on each primary, not a separately-elected proceeding type).
### 0.5 Counts reconciled
| Group | Count | Total of 46 |
|---|---:|---:|
| A.1 Primary with corpus (18 rows) | 18 | |
| A.2 Primary, unloaded (4 rows) | 4 | |
| B Phases (5 rows) | 5 | |
| C Side-actions (10 rows) | 10 | |
| D Meta / cross-cutting (9 rows) | 9 | |
| **Total** | | **46 ✓** |
m/paliad#147's audit listed 8 Group-D rows; live data shows 9 once `upc.reestablishment.rop` is moved into the meta bucket (it appeared as ambiguous "cross-cutting admin / meta" — confirming this design's read).
---
## 1. Categorization — ratified
The taxonomy proposal: a row in `paliad.proceeding_types` has exactly one of four **structural kinds**.
| `kind` | What it is | Visible in Mode B R3 wizard? | In `pkg/litigationplanner` catalog? | Eligible for `projects.proceeding_type_id`? |
|---|---|---|---|---|
| `proceeding` | A self-contained matter with its own filing pathway and its own deadline tree | **Yes** | **Yes** (filtered by `kind='proceeding' AND is_active=true`) | **Yes** |
| `phase` | A stage *within* a primary proceeding | No | No | No |
| `side_action` | An application/order that arises inside a primary proceeding | No | No | No |
| `meta` | RoP mechanics, cross-cutting rules, court administration | No | No | No |
This is **Model 1 from m/paliad#147** (kind discriminator on `proceeding_types`). §2 explains why it beats Models 2-4 for the actual data.
The 46 active rows map to the 4 kinds as follows:
- **`proceeding` (22 rows):** all 18 primaries-with-corpus + the 4 unloaded primaries from §0.3. Specifically the union of §0.2 + §0.3.
- **`phase` (5 rows):** the §0.4 Group B list.
- **`side_action` (10 rows):** the §0.4 Group C list.
- **`meta` (9 rows):** the §0.4 Group D list (incl. `upc.reestablishment.rop`).
### 1.1 Edge calls
- **`upc.ccr.cfi` (id 35)** — stays `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1 (the determinator surfaces it, the mapping returns inf.cfi's id with `with_ccr=true`). Rationale: the routing layer is already built and m ratified it 2026-05-18. This design does not re-open that decision. §9 Q7 lets m revisit.
- **`upc.pl.cfi` (Schutzschrift, id 188)** — borderline. Schutzschrift is filed *before* a proceeding exists; it's a defensive pre-litigation filing. Recommendation: keep as `kind='proceeding'` (it has its own RoP path + its own deadlines once seeded). The alternative — calling it `side_action` of a not-yet-existing inf.cfi — is semantically backwards. §9 Q3.b lets m revisit.
- **`upc.bsv.cfi` (saisie, id 179)** vs **`upc.evidence.cfi` (id 178)** — bsv stays `kind='proceeding'` (own RoP filing under R.192-198), evidence stays `kind='side_action'` (the orders a court makes inside any proceeding under R.190). The codes are not duplicates.
### 1.2 What the categorisation buys
- **Mode B R3 (Fristenrechner overhaul, t-paliad-322)** queries `proceeding_types WHERE is_active AND kind='proceeding'` and gets a clean 22-row pick list — no phase/side-action/meta noise.
- **`projects.proceeding_type_id` integrity** is enforceable: an FK + CHECK (or a triggered constraint, see §3.3) blocks setting a project's type to anything except `kind='proceeding'`.
- **`pkg/litigationplanner` snapshot generator** filters identically; youpc.org's catalog stays UPC-primary-only with no leakage of phase/admin rows.
- **Determinator + dropdowns** get a forward-compatible filter; future feature work (e.g. "show me all side-actions available in this proceeding") becomes a different query against the same table.
- **Forward-compatibility for new rows** — when corpus for a side-action arrives (e.g. `upc.evidence.cfi` gains 4 sequencing_rules with `condition_expr='evidence_order_issued'`), the rules anchor on the *parent* primary, not on the side-action row. The kind classification stays correct; the side-action row remains a taxonomic label.
---
## 2. Model choice — Model 1 (kind discriminator)
### 2.1 The four candidate models, scored
| Model | Schema churn | Models phase parentage? | Mode B R3 filter | Migration risk | Verdict |
|---|---|---|---|---|---|
| **1. `kind` discriminator on `proceeding_types`** | One column + CHECK constraint | No, but doesn't need to | `WHERE kind='proceeding'` | Trivial — UPDATE only | **Recommended** |
| 2. Self-referencing `parent_id` | One column + FK + CHECK | Yes, but parentage is wrong shape (phases are phase-of-EVERY-CFI, not of one) | `WHERE parent_id IS NULL` | Trivial | Over-modelled |
| 3. Separate tables | Three new tables + view/JOINs | Yes, fully | Just query `proceeding_types` | Migration churn + every consumer query learns a new shape | Overkill for 28 unused rows |
| 4. Move phases into `procedural_events` | One mass row-move + DELETE | n/a (phases vanish from `proceeding_types`) | Trivial | Highest — would touch event_kind taxonomy and Fristenrechner result-view structure | Wrong shape (phases ≠ events) |
### 2.2 Why Model 1 wins
The fundamental observation: **the 28 non-primary rows have zero downstream pressure**. No rule, no project, no concept, no spawn FK references them. They exist in the table as taxonomic placeholders — names someone wrote down so future corpus could attach. We don't need to physically restructure the table; we just need to label what's what so consumers can filter correctly.
Model 1 gives us exactly that with one column. The other models pay schema/migration cost to model a parent-child relationship that **no consumer queries**. Mode B R3 doesn't ask "what are the phases of upc.inf.cfi?" — it asks "what are the proceedings I can pick?". The Fristenrechner result view doesn't ask the proceeding-types table about phases — phases live inside `procedural_events.event_kind` and the priority-bucket sub-sections in the §4.2 of the Fristenrechner overhaul doc.
Model 2's `parent_id` is wrong in shape: `upc.cfi.interim` doesn't have ONE parent (`upc.inf.cfi`), it has SEVEN parents (every CFI proceeding). Modelling that as a self-reference would force either (a) duplicating the phase rows per primary, or (b) using NULL parent_id for "applies to all". Both options are uglier than just dropping parent_id and trusting `kind='phase'`.
Model 3's separate tables would create rich relations that no consumer reads. Premature relational normalisation.
Model 4 would force phases into `procedural_events`, but phases aren't events. A phase is a *bucket of events*. The bucket is already implicit in the `event_kind` column (filing → interim, hearing → oral, decision → decision). If anything, Model 4 is *backwards* — phases should disappear into `event_kind`, not become event rows. The way to "delete" the phase rows from proceeding_types is just to deactivate them (or mark them `kind='phase'`); we don't need to re-locate them into another table to claim that conceptual move.
### 2.3 What we don't do — physical deletion
The 28 non-primary rows are NOT dropped from the table. They:
- Get tagged with the right `kind` value.
- Optionally get `is_active=false` flipped (m's call, §9 Q9).
- Stay in the table so consumers that historically referenced them by id (admin tools, audit logs, future schema-rescue scripts) keep working.
`DROP` is a one-way door we don't need to walk through. The CHECK constraint + kind tagging gives us the same logical cleanliness with none of the irreversibility risk.
---
## 3. Schema sketch + migration plan
### 3.1 DDL — the new column
```sql
-- Migration NNN_proceeding_types_kind.up.sql
-- (NNN = whatever MAX(version) + 1 is at write time; see project-status.md
-- for the live numbering. As of 2026-05-26 the head is mig 152 per the
-- recent dedupe of identical sequencing_rule clones.)
ALTER TABLE paliad.proceeding_types
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
COMMENT ON COLUMN paliad.proceeding_types.kind IS
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
'proceeding = self-contained matter (own filing + deadline tree); '
'phase = stage inside a primary CFI proceeding; '
'side_action = application/order inside a proceeding; '
'meta = RoP mechanics, court admin, cross-cutting remedies.';
CREATE INDEX proceeding_types_kind_active_idx
ON paliad.proceeding_types(kind, is_active)
WHERE is_active = true;
```
The DEFAULT keeps existing inserts (admin tooling, snapshot tests) safe: any new row defaults to `proceeding`. The CHECK enforces the vocabulary at write time.
### 3.2 Data move — UPDATE statements, no INSERT/DELETE
```sql
-- Phases (per m's Q2 carve-out: upc.costs.cfi (176) is NOT a phase, it stays primary)
UPDATE paliad.proceeding_types
SET kind = 'phase'
WHERE id IN (173, 174, 175, 185); -- §0.4 Group B minus 176
-- Side-actions
UPDATE paliad.proceeding_types
SET kind = 'side_action'
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183); -- §0.4 Group C
-- Meta / cross-cutting
UPDATE paliad.proceeding_types
SET kind = 'meta'
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169); -- §0.4 Group D
-- Primaries (incl. m's Q2 carve-out for upc.costs.cfi) stay on the DEFAULT
-- 'proceeding' value — no UPDATE needed.
-- Per m's Q9: deactivate the non-primary rows so the admin list surfaces only
-- primaries. The kind column carries the semantic info; is_active controls UI
-- visibility. Reversible — flip is_active back on if a row gains corpus.
UPDATE paliad.proceeding_types
SET is_active = false
WHERE kind IN ('phase', 'side_action', 'meta');
```
Per m's Q9, the `is_active=false` flip is mandatory in this mig. After it: 23 active rows (all `kind='proceeding'`), 23 inactive rows (the phase/side_action/meta set), in addition to the pre-existing inactive appeal-triplet + archived bucket. The `kind` column tells consumers what each row IS; `is_active` tells consumers whether to show it.
### 3.3 Optional integrity constraints
If m wants stronger guarantees that `projects.proceeding_type_id` can only point at primaries, add a deferrable FK validator. Cleanest pattern in Postgres:
```sql
-- Option A: trigger-based check (works for any kind set, deferred-friendly).
CREATE OR REPLACE FUNCTION paliad.assert_project_type_is_proceeding()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.proceeding_type_id IS NOT NULL THEN
PERFORM 1 FROM paliad.proceeding_types
WHERE id = NEW.proceeding_type_id AND kind = 'proceeding';
IF NOT FOUND THEN
RAISE EXCEPTION 'projects.proceeding_type_id must reference a kind=proceeding row, got id=%', NEW.proceeding_type_id
USING ERRCODE = '23514';
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER projects_proceeding_type_kind_check
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
FOR EACH ROW EXECUTE FUNCTION paliad.assert_project_type_is_proceeding();
```
Per m's Q8: **trigger on `projects` only**, no symmetric enforcement on `sequencing_rules`. Projects are written via the public app (the surface most exposed to operator error); rules are edited via the admin `/admin/procedural-events` surface which already validates against active+published lifecycle. The single trigger is enough.
### 3.4 Migration sequencing — single self-contained mig
One migration file:
```
internal/db/migrations/153_proceeding_types_kind.up.sql
internal/db/migrations/153_proceeding_types_kind.down.sql
```
Up does ALTER + UPDATE + (optional) trigger creation. Down does DROP COLUMN (cascading the trigger if present). No data loss on either direction — the kind column is purely additive.
Mig number depends on what knuth lands first; the coder reads `MAX(version)` at write time per the project's mig conventions.
---
## 4. FK reparenting tables
There is no reparenting to do. Below for completeness:
| Source table.column | Pointing at non-primary rows? | Action |
|---|---|---|
| `sequencing_rules.proceeding_type_id` | **0 active rules** (verified §0.1) | None |
| `sequencing_rules.spawn_proceeding_type_id` | **0 active rules** point at non-primaries; 4 active rules point at id=11 (inactive `upc.apl.merits`) | Pre-existing drift, out of scope (§8) |
| `projects.proceeding_type_id` | **0 projects** (all 6 distinct values are primaries) | None |
| `event_category_concepts.proceeding_type_code` | **0 concepts** point at non-primary codes; 30 concepts point at `upc.apl.merits/order/cost` codes (which are inactive but conceptually primaries) | Pre-existing drift, out of scope (§8) |
The "FK reparent" section of the acceptance criteria in m/paliad#147 is a no-op for this design: the 28 rows being re-classified have **no incoming references** to reparent. The migration is pure relabelling.
---
## 5. Worked example — `upc.cfi.interim` after the mig
### 5.1 Today (broken)
Someone created the row `upc.cfi.interim` (id 173, name "CFI - Zwischenverfahren") in `paliad.proceeding_types` with `category='fristenrechner'`. The intent was probably "we'll attach interim-phase rules here later". Result:
- The row appears in the Mode B R3 wizard chip strip (if R3 queries `WHERE is_active=true AND jurisdiction='UPC'`) — confusing to the user, because "Zwischenverfahren" is not a proceeding they pick; it's a stage their proceeding passes through.
- The row could be set as `projects.proceeding_type_id` (no FK constraint forbids it today) — corrupting the SmartTimeline's lane logic, which assumes the project's type is a primary.
- The row appears in admin /admin/proceeding-types lists, polluting the primary-proceedings overview.
### 5.2 After mig 153
The migration runs:
```sql
UPDATE paliad.proceeding_types SET kind = 'phase' WHERE id = 173;
-- Optionally: UPDATE paliad.proceeding_types SET is_active = false WHERE id = 173;
```
Now:
- Mode B R3 query becomes `WHERE is_active=true AND jurisdiction = $1 AND kind='proceeding'`. `upc.cfi.interim` is filtered out — it is not a "Verfahren" the user can pick.
- A future admin who tries to set a project's `proceeding_type_id = 173` either fails the optional trigger from §3.3 (with a clear error) or gets a code-level rejection from `ProjectService.SetProceedingType` (which the coder will harden to filter by `kind='proceeding'`).
- The `pkg/litigationplanner` snapshot generator filter becomes `WHERE is_active=true AND category='fristenrechner' AND kind='proceeding' AND jurisdiction IN ('UPC')`. The row never makes it into the youpc.org catalog.
The row itself stays in the database. Its id is stable. Future work that wants to *use* the phase row as a taxonomic label (e.g. "show me which event_kinds map to which UPC phases") gets a clean shape: query `WHERE kind='phase' AND code LIKE 'upc.cfi.%'`.
### 5.3 Where interim-phase deadlines actually live
The user-facing concept "interim phase" is already modelled correctly, just elsewhere:
- A `procedural_events` row like `upc.inf.cfi.soc` (Statement of Claim) has `event_kind='filing'`. The Fristenrechner overhaul (t-paliad-322 §4.2) groups follow-ups by priority + presents them under the trigger card. There is no UI element that needs a "Zwischenverfahren" proceeding-type label to operate.
- A future "show me the full ablauf of UPC inf, broken down by phase" feature can derive phases from `procedural_events.event_kind` ordering + the rule sequence_order. The `proceeding_types` table doesn't need to carry the phase labels.
---
## 6. Consumer impact
### 6.1 `projects.proceeding_type_id`
| Concern | Before | After mig 153 |
|---|---|---|
| Valid values | Any active proceeding_types row | Any `kind='proceeding'` active row (22 rows) |
| Enforcement | None at DB level | Optional trigger (§3.3 / §9 Q8) |
| Code-level filter in ProjectService | No filter on kind | Filter to `kind='proceeding'` when listing pickable types |
| Existing data | 6 distinct values (all in 22) | No change — all 6 are kind='proceeding' |
| SmartTimeline lane logic | Assumes primary-proceeding shape | Assumption now FK-enforceable |
**No data migration on existing projects.** The 6 currently-used proceeding types are all in the primary set.
### 6.2 `sequencing_rules.proceeding_type_id` + `spawn_proceeding_type_id`
| Concern | Before | After mig 153 |
|---|---|---|
| `proceeding_type_id` valid values | Any active row | Any active row (no enforcement change; admin curation suffices) |
| `spawn_proceeding_type_id` valid values | Any active row | Same — spawns conceptually must point at a primary, but enforcement stays in admin tooling |
| Existing data | 157 rules anchored on 18 primaries | No change — all 157 already on `kind='proceeding'` rows |
| `id=11 spawn pressure` (`upc.apl.merits`, inactive) | 4 active spawn rules point here | Pre-existing drift, out of scope (§8) |
No `sequencing_rules` table changes accompany this mig. The post-mig invariant **"every active rule's `proceeding_type_id` is a `kind='proceeding'` row"** holds without any UPDATE.
### 6.3 Fristenrechner Mode B R3 (t-paliad-322, knuth's S3+)
§3.2 R3 of the Fristenrechner overhaul says:
> Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind.
After mig 153, the R3 query gains one more AND-clause:
```sql
SELECT pt.id, pt.code, pt.name, pt.name_en, pt.sort_order
FROM paliad.proceeding_types pt
WHERE pt.is_active = true
AND pt.kind = 'proceeding' -- NEW
AND pt.jurisdiction = $1 -- from R2
AND EXISTS (
SELECT 1 FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.proceeding_type_id = pt.id
AND pe.event_kind = $2 -- from R1
AND sr.is_active = true
)
ORDER BY pt.sort_order, pt.code;
```
The `kind='proceeding'` filter is the only line that changes. Knuth's S3 implementation reads from this query; the chip pool shrinks from "all 35 active UPC types" to "the 14 primary UPC types that have rules" (still narrowed further by R1's event_kind via the EXISTS subquery).
No coder churn beyond adding the AND-clause. The mig 153 lands either alongside knuth's S3 work or independently (§7 sequencing decision).
### 6.4 Litigation Planner suite (t-paliad-292)
The package's catalog snapshot generator (`pkg/litigationplanner/scripts/snapshot/main.go`) currently filters:
```go
// scripts/snapshot/main.go
const proceedingTypesQuery = `
SELECT id, code, name, name_en, jurisdiction, default_color, sort_order, display_order,
trigger_event_label_de, trigger_event_label_en
FROM paliad.proceeding_types
WHERE is_active = true
AND category = 'fristenrechner'
AND jurisdiction = $1
`
```
After mig 153, this query gains the same `AND kind = 'proceeding'` line. The UPC snapshot shrinks from "potentially 35 rows" to a clean primary-only set. Today's snapshot probably already includes the phase/side-action/meta rows (since `is_active=true` is true for all of them) — depending on whether a snapshot has been regenerated since the 161-188 rows landed, the embedded JSON may be carrying decorative rows that the youpc.org catalog never resolves to rules. Mig 153 + a snapshot regen cleans this up.
The package's `Catalog.Proceeding(ctx, code, hint)` interface stays unchanged. A youpc-side call asking for `code='upc.cfi.interim'` previously returned the row + zero rules (technically valid but useless); after mig 153 the snapshot doesn't include it and the call returns `ErrUnknownProceedingType`. That's the correct shape — youpc users never had a reason to ask for a phase row.
The scenarios design (`paliad.scenarios.spec.proceedings[].code`) gains an integrity check at write time: the validator already asserts every code resolves to an active proceeding; now it additionally asserts `kind='proceeding'`. A user trying to compose a scenario with `code='upc.cfi.interim'` gets a clear error. (The validator is paliad-side, not library-side — see Litigation Planner doc §5 "Validatable at write time".)
### 6.5 Admin /admin/procedural-events list (recently shipped, t-paliad-321)
The proceeding-type column in the admin list (m/paliad#144 follow-up, just landed) renders one of the 46 active codes per row. Post-mig 153, the admin filter dropdown can:
- Default to showing only `kind='proceeding'` rows (clean primary view).
- Offer a "show all kinds" toggle for admins triaging the non-primary rows.
This is presentation-only — the underlying admin queries don't need to change immediately. The kind column is a forward-compat hook.
### 6.6 Knowledge-platform pages (Gerichtsverzeichnis, Patentglossar)
Untouched. None of those pages query `proceeding_types` directly.
### 6.7 Fristen export / paliad data export (t-paliad-279)
Untouched. The exporter dumps `proceeding_types` as a whole (no kind-filter); after mig 153 it dumps the same rows with the new kind column. Forward-compat by default.
---
## 7. Migration sequencing decision vs m/paliad#146
m/paliad#146 (Fristenrechner overhaul, t-paliad-322 / 323) is on the S1-S6 train under knuth. m's directive at task brief time: **knuth pauses at the S1+S2 seam waiting for this taxonomy decision**.
Three options were on the table:
(a) **Pause #146 until taxonomy clean** — knuth blocked, this design lands first, then knuth resumes S3+.
(b) **Land #146 against current shape, migrate later** — knuth ships S3-S6 against the current 46-row table, taxonomy mig follows.
(c) **Land taxonomy in parallel, knuth re-targets if needed** — both run, knuth's S3 picks up the new filter when mig 153 is ready.
**Recommendation: (c) parallel-land** with the following caveats:
- The taxonomy mig is **additive** (ADD COLUMN with safe DEFAULT, no DROP, no data move beyond UPDATEs that touch unreferenced rows). Knuth's S3 implementation can be written with or without the `kind='proceeding'` filter — adding the filter is a one-line patch the moment mig 153 lands.
- The R3 chip-pool query in knuth's S3 PR should be **future-proofed by also adding the `kind='proceeding'` filter behind a feature flag or an env-time SQL constant**, defaulting to "no filter" pre-mig and "filter" post-mig. (Or simpler: knuth writes the filter unconditionally; the migration lands first; ordering is mechanical.)
- The mig 153 PR should land **before** knuth's S3 PR ships to main, so the filter is never false-positive (chipping phase rows users can't actually pick). Both PRs can be drafted in parallel; the squeeze happens at merge time.
- Sequence on main: mig 153 → knuth S3 (with filter) → knuth S4-S6.
Option (c) keeps knuth productive (S3 work can start immediately after this design ratifies; doesn't have to wait for the mig to merge) and avoids the option (a) idle cost.
Option (b) was rejected because it leaves the Mode B R3 wizard chipping 35 UPC rows on initial release — exactly the bug m flagged in m/paliad#147 ("half of the 46 active proceeding_types are not primary proceedings"). The user would see phase rows in R3 day one of the Fristenrechner overhaul shipping; we'd be shipping the bug.
Option (a) was rejected as the safest but slowest path. The taxonomy mig is trivial enough (one ALTER + four UPDATE statements + optional trigger) that parallel-running has no real risk.
§9 Q10 gives m the chance to pick differently.
---
## 8. Out of scope (flagged for separate work)
- **`upc.apl.*` data drift.** 30 rows in `paliad.event_category_concepts` reference the inactive `upc.apl.merits` / `upc.apl.order` / `upc.apl.cost` codes (the pre-`upc.apl.unified` triplet). 4 active sequencing_rules reference `spawn_proceeding_type_id=11` (the inactive `upc.apl.merits` row). This is a pre-existing inconsistency from the appeal unification mig — needs its own follow-up ticket. Not blocking this design; can be cleaned up in a separate migration that retargets concepts + spawn FKs to `upc.apl.unified` (id=160).
- **Renaming or relabelling primary proceedings.** Out per m/paliad#147 acceptance — editorial work, not structural.
- **Adding new proceeding types beyond the existing corpus.** Out per m/paliad#147 acceptance.
- **The Fristenrechner UI overhaul itself (m/paliad#146).** Separate track; this design only tells knuth's S3 what set to chip.
- **The scenarios design (m/paliad#124).** Already ratified in `docs/design-litigation-planner-2026-05-26.md` §5; this design only refines the spec validator's "every code resolves to a primary" check.
- **DROPing the non-primary rows physically.** Reversible deactivation via `kind=...` + optional `is_active=false` is enough; physical deletion adds irreversibility risk for no functional gain.
- **Migration of `event_category_concepts.proceeding_type_code` to a real FK.** It's text today, joined softly; converting to FK is a separate hardening task.
---
## 9. Open questions for m (10 decision questions)
Sent via `AskUserQuestion` in 3 batches per inventor SKILL contract (4+3+3). m's picks land in §10 below after the round-trip.
| # | Topic | Recommended pick |
|---|---|---|
| Q1 | Model choice | Model 1 (kind discriminator) |
| Q2 | Phases — linear sub-phases of every CFI, or separately-elected? | Implicit: phases live in `procedural_events.event_kind`, not as proceeding_types |
| Q3.a | Side-actions — triggered by parent event, or initiated out-of-band? | Mixed; today's data has no rules, future rules anchor on the parent primary with `condition_expr` |
| Q3.b | `upc.pl.cfi` (Schutzschrift) — primary or side-action? | Primary (own RoP filing pathway) |
| Q4 | Collapse `de.inf.lg`/`olg`/`bgh` into one `de.inf` with instance_level qualifier? | No — keep discrete |
| Q5 | Collapse `de.null.bpatg`/`bgh` into one `de.null` with instance_level qualifier? | No — keep discrete |
| Q6 | Should DE follow the `upc.apl.unified` pattern? | No (= keep discrete, locks Q4+Q5) |
| Q7 | `upc.ccr.cfi` — proceeding row with routing (status quo), or `with_ccr` flag on `upc.inf.cfi`? | Keep as proceeding (status quo per t-paliad-204 S1) |
| Q8 | Enforce `projects.proceeding_type_id``kind='proceeding'` at the DB level? | Yes, via trigger (§3.3) |
| Q9 | Set `is_active=false` on the 28 non-primary rows after mig 153? | Yes (cleanest admin UX) |
| Q10 | Sequencing vs m/paliad#146 — pause / parallel / re-target? | (c) parallel-land — mig first, then knuth S3 with filter |
Q11 in the issue body ("how many rules need new condition_expr disambiguation?") is **empirically answered, no decision needed**: 0 rules need new condition_expr — every active rule is already correctly anchored to a primary. Surfaced in §4 + §6.2.
---
## 10. m's decisions (2026-05-27)
All 11 questions answered via `AskUserQuestion` on 2026-05-27 09:52 (3 batches of 4+4+3). 10 of 11 picks = recommendation; Q9 diverged at the chip-picker but m's follow-up instruction ("I follow your recommendation") flips Q9 to the recommendation as well. Q2 carries a precise carve-out captured verbatim below.
- **Q1 (Model): Model 1 — kind discriminator.** [= recommendation] One column + CHECK constraint + UPDATE statements. **Locks §1, §2, §3.1, §3.2.**
- **Q2 (Phases): Generally option 1 (implicit via `procedural_events.event_kind`), with carve-outs.** [≈ option 1 with carve-out] m's verbatim call:
> Generally 1, but I agree with costs which are not only a phase but also "standalone" side proceedings. But default decision application is not.
Concretely:
- `upc.cfi.interim` (173) → `kind='phase'`
- `upc.cfi.oral` (174) → `kind='phase'`
- `upc.cfi.decision` (175) → `kind='phase'`
- `upc.default.cfi` (185) → `kind='phase'` (m: "default decision application is not [a standalone side proceeding]")
- **`upc.costs.cfi` (176) → `kind='proceeding'`** (m: "costs are not only a phase but also standalone side proceedings"). The Separate Kostenentscheidung can be filed as its own application under R.151 RoP independently of the parent decision; m's read is that the standalone-application character outweighs the phase-of-CFI character.
Net: 4 phase rows (not 5 as in the strawman), 23 primary-proceeding rows (not 22). **Updates §0.4 Group B count, §0.5 totals row, §1 categorisation, §3.2 UPDATE statement IDs (drop 176 from the phase UPDATE).**
- **Q3.a (Side-actions): kind='side_action', rules anchor on parent primary.** [= recommendation] All 10 §0.4 Group C rows get `kind='side_action'`. When corpus arrives, rules attach to the parent primary with a `condition_expr` flag. **Locks §1.1, §3.2 side-action UPDATE.**
- **Q3.b (Schutzschrift): kind='proceeding'.** [= recommendation] `upc.pl.cfi` (188) stays in the primary set on the strength of its own RoP filing pathway. **Locks §0.3 unloaded-primary list.**
- **Q4 (DE inf collapse): Keep discrete.** [= recommendation] `de.inf.lg/olg/bgh` stay as 3 separate primaries. No collapse, no instance_level qualifier introduction. **Locks §0.2 + §1 DE-side categorisation.**
- **Q5 (DE null collapse): Keep discrete.** [= recommendation] `de.null.bpatg/bgh` stay separate. Symmetric with Q4. **Locks §0.2 + §1 DE-side categorisation.**
- **Q6 (DE follow upc.apl pattern): No — keep DE discrete.** [= recommendation] Locks Q4+Q5. The `upc.apl.unified` consolidation was about same-court appeal variants; DE appeals are different-court-instance appeals — different problem. **No code-rename work falls out of this design.**
- **Q7 (CCR shape): Keep status quo.** [= recommendation] `upc.ccr.cfi` stays as `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1. **Locks §1.1.**
- **Q8 (DB trigger): Trigger on `projects` only.** [= recommendation] BEFORE INSERT/UPDATE trigger on `paliad.projects` enforces `proceeding_type_id → kind='proceeding'`. No trigger on `sequencing_rules` (admin tooling already gates). **Locks §3.3 — keep the `projects` trigger DDL, drop the optional `sequencing_rules` variant.**
- **Q9 (Deactivate non-primaries): Yes — deactivate.** [m's chip-pick was "keep active"; flipped to recommendation per m's "I follow your recommendation" instruction] All `kind IN ('phase', 'side_action', 'meta')` rows get `is_active=false` in mig 153. The admin `/admin/proceeding-types` list shows only the 23 active primaries. Rows stay in the table with their `kind` tag so future tooling that wants to surface them can flip `is_active` back on. **Updates §3.2 — uncomment the optional `UPDATE … SET is_active=false` block.**
- **Q10 (Sequencing vs #146): Parallel-land.** [= recommendation] Mig 153 + knuth's S3 PR drafted in parallel; mig merges first; knuth's S3 includes the `kind='proceeding'` filter in R3's chip query from day one. No idle cost; no bug shipped. **Locks §7.**
### 10.1 What changed from the strawman as a result
Two material edits flow from m's picks:
1. **§0.4 Group B (Phases) drops `upc.costs.cfi` (id 176)** — moved into the primary set. Phase count: 5 → 4. Primary count: 22 → 23. §0.2 picks up id 176 as an unloaded primary (zero rules today; future corpus will attach).
2. **§3.2 migration includes the `is_active=false` UPDATE** (was optional in the strawman, now mandatory):
```sql
UPDATE paliad.proceeding_types
SET is_active = false
WHERE kind IN ('phase', 'side_action', 'meta');
```
This is what the post-mig 153 cleanup looks like: 23 active rows (all `kind='proceeding'`), 23 inactive rows (4 phase + 10 side_action + 9 meta + the pre-existing 3 inactive appeal-triplet + 1 archived bucket = 27 inactive total, but 23 of those are the freshly-deactivated taxonomy rows).
These edits don't change the §7 sequencing decision or the §6 consumer-impact analysis. They tighten the mig file and shift one row's classification.
### 10.2 Final categorisation (post-decisions)
| `kind` | Count | Codes |
|---|---:|---|
| `proceeding` | **23** | upc.inf.cfi, upc.rev.cfi, upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, upc.ccr.cfi, upc.apl.unified, upc.dni.cfi, upc.epo.review, upc.bsv.cfi, upc.pl.cfi, **upc.costs.cfi** (m's Q2 carve-out), de.inf.lg, de.inf.olg, de.inf.bgh, de.null.bpatg, de.null.bgh, epa.opp.opd, epa.opp.boa, epa.grant.exa, dpma.opp.dpma, dpma.appeal.bpatg, dpma.appeal.bgh |
| `phase` | **4** | upc.cfi.interim, upc.cfi.oral, upc.cfi.decision, upc.default.cfi |
| `side_action` | **10** | upc.evidence.cfi, upc.experiments.cfi, upc.security.cfi, upc.intervention.rop, upc.parties.change, upc.optout.cfi, upc.inspection.cfi, upc.freezing.cfi, upc.withdrawal.rop, upc.rehearing.coa |
| `meta` | **9** | upc.case.mgmt, upc.general.rop, upc.service.rop, upc.language.rop, upc.representation.rop, upc.fees.court, upc.legalaid.cfi, upc.special.cfi, upc.reestablishment.rop |
| **Total** | **46** | ✓ |
Post-mig 153: 23 active (all `kind='proceeding'`), 23 deactivated (the phase/side_action/meta set).
---
## 11. Synthesis links
- mBrian topic: `topic-fristenrechner` — file this design as a `[synthesis]` node, link `related_to` the proceeding-code-taxonomy doc (2026-05-18) and the Fristenrechner overhaul (2026-05-26), `triggered_by` t-paliad-324.
- Related design docs: `docs/design-proceeding-code-taxonomy-2026-05-18.md` (the code-shape doc), `docs/design-fristenrechner-overhaul-2026-05-26.md` (knuth's parent design), `docs/design-litigation-planner-2026-05-26.md` §5 (scenarios spec validator).
- Related migrations: 095 (fristen gap-fill, spawn FK invariant), 096 (proceeding code rename), 152 (sequencing_rule dedupe + admin column).

View File

@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import {
defaultChecked,
groupFollowUps,
type FollowUpRule,
} from "./fristenrechner-result";
// Pure helpers exercised here; the DOM-driven render path is covered
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
// entry-mode UIs in later slices).
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
return {
rule_id: "r" + Math.random().toString(36).slice(2, 8),
event_code: "evt",
title_de: "Frist",
title_en: "Deadline",
priority: "mandatory",
is_court_set: false,
is_spawn: false,
is_bilateral: false,
has_condition: false,
...partial,
};
}
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
test("groups by priority; conditional takes precedence over priority", () => {
const rows = [
mk({ priority: "mandatory" }),
mk({ priority: "recommended" }),
mk({ priority: "optional" }),
mk({ priority: "mandatory", has_condition: true }), // → conditional
mk({ priority: "optional", has_condition: true }), // → conditional
];
const g = groupFollowUps(rows);
expect(g.mandatory.length).toBe(1);
expect(g.recommended.length).toBe(1);
expect(g.optional.length).toBe(1);
expect(g.conditional.length).toBe(2);
});
test("unknown priority falls through to optional", () => {
const g = groupFollowUps([mk({ priority: "informational" })]);
expect(g.optional.length).toBe(1);
expect(g.mandatory.length).toBe(0);
});
});
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
test("mandatory rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
});
test("recommended rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
});
test("optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
});
test("conditional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
});
test("court-set rules unchecked even when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
});
test("spawned rules pre-checked when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
});
test("spawned optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
});
});

View File

@@ -0,0 +1,611 @@
// Fristenrechner overhaul — shared result view (design §4).
//
// Given a locked trigger event + a trigger date, this module renders
// the result surface: a sticky trigger card on top, then four priority
// groups (mandatory / recommended / optional / conditional) of follow-up
// rules with computed dates, then a write-back footer that calls the
// existing POST /api/projects/{id}/deadlines/bulk.
//
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
// wizard in S4) both land here once they've identified a trigger
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
// services.FollowUpsResponse server-side.
export interface FollowUpRule {
rule_id: string;
event_code: string;
title_de: string;
title_en: string;
priority: string;
primary_party?: string;
duration_value?: number;
duration_unit?: string;
timing?: string;
due_date?: string;
original_due_date?: string;
was_adjusted?: boolean;
is_court_set: boolean;
is_spawn: boolean;
is_bilateral: boolean;
has_condition: boolean;
rule_code?: string;
legal_source?: string;
legal_source_display?: string;
legal_source_url?: string;
notes_de?: string;
notes_en?: string;
spawn_label?: string;
spawn_proceeding_code?: string;
concept_id?: string;
}
export interface FollowUpsResponse {
trigger: {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
anchor_rule_id: string;
};
trigger_date: string;
party?: string;
follow_ups: FollowUpRule[];
}
// Per-rule UI state — checkbox, optional date override.
interface RuleSelection {
checked: boolean;
override?: string;
}
// Module-local state. Single result view at a time; the surface
// re-renders in place when the user changes the trigger date or
// re-locks a different event.
let currentResponse: FollowUpsResponse | null = null;
const selections = new Map<string, RuleSelection>();
let currentProjectId: string | null = null;
// Public API ----------------------------------------------------------
// isOverhaulMode reports whether the page is in overhaul mode (S2+).
// True when `?overhaul=1` is present. Once S5 flips the flag, the
// reverse check (?legacy=1) replaces this.
export function isOverhaulMode(): boolean {
return new URLSearchParams(window.location.search).get("overhaul") === "1";
}
// resolveProjectId reads the active Akte from the URL query string.
// Returns null when in kontextfrei mode (no project picked).
function resolveProjectId(): string | null {
const p = new URLSearchParams(window.location.search).get("project");
return p && p.length > 0 ? p : null;
}
// MountOptions configures the surface entry. Both entry-mode paths
// (Mode A in S3, Mode B in S4) call mount() with the event reference
// that the user committed.
export interface MountOptions {
// eventRef is the procedural_event code OR its uuid OR the anchor
// sequencing_rule id. Resolved server-side; the wire returns the
// canonical code so the URL bookmark is stable.
eventRef: string;
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
triggerDate?: string;
// party is "claimant" | "defendant"; mode A may pass "both" or
// "court". When omitted, follow-ups are returned without party
// narrowing.
party?: string;
// courtId selects the holiday calendar for the per-rule date
// adjustment. Optional.
courtId?: string;
}
// mountResultView fetches /follow-ups and renders the result surface
// into the host container. Re-callable: replaces previous state.
export async function mountResultView(opts: MountOptions): Promise<void> {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.hidden = false;
const triggerDate = opts.triggerDate || todayIso();
currentProjectId = resolveProjectId();
// Show a quick "loading…" placeholder so the user sees something
// immediately, even on a cold fetch.
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
url.searchParams.set("event", opts.eventRef);
url.searchParams.set("trigger_date", triggerDate);
if (opts.party) url.searchParams.set("party", opts.party);
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
let data: FollowUpsResponse;
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
return;
}
data = (await resp.json()) as FollowUpsResponse;
} catch (err) {
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
return;
}
currentResponse = data;
selections.clear();
for (const r of data.follow_ups) {
selections.set(r.rule_id, { checked: defaultChecked(r) });
}
renderSurface();
// Reflect the canonical event code + trigger date in the URL so the
// deep-link survives a reload.
syncUrlState(data.trigger.code, data.trigger_date);
}
// Render --------------------------------------------------------------
function renderSurface(): void {
const root = document.getElementById("fristen-overhaul-root");
if (!root || !currentResponse) return;
const lang = getLang();
const trig = currentResponse.trigger;
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
const juris = trig.proceeding_type.jurisdiction || "";
const kindIcon = eventKindIcon(trig.event_kind);
const triggerCard = `
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
<header class="fristen-overhaul-trigger-header">
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
</header>
<div class="fristen-overhaul-trigger-meta">
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
</div>
<div class="fristen-overhaul-trigger-date">
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
${escHtml(t("deadlines.overhaul.trigger.date"))}
</label>
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
value="${escAttr(currentResponse.trigger_date)}" />
</div>
</section>
`;
const groups = groupFollowUps(currentResponse.follow_ups);
const groupHtml = renderGroups(groups, lang);
const nudge = currentProjectId
? ""
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
const footer = currentProjectId
? renderFooter()
: "";
root.innerHTML = `
${triggerCard}
${nudge}
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
${groupHtml}
</section>
${footer}
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
`;
wireSurfaceEvents();
}
export interface GroupedFollowUps {
mandatory: FollowUpRule[];
recommended: FollowUpRule[];
optional: FollowUpRule[];
conditional: FollowUpRule[];
}
// groupFollowUps splits the wire list into the four visible groups per
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
// precedence over the priority bucket so a "nur wenn CCR" mandatory
// rule renders under Conditional with the gating language visible.
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
for (const r of rows) {
if (r.has_condition) {
out.conditional.push(r);
continue;
}
switch (r.priority) {
case "mandatory":
out.mandatory.push(r);
break;
case "recommended":
out.recommended.push(r);
break;
case "optional":
out.optional.push(r);
break;
default:
// unknown / informational — fold into optional so the row is at
// least visible. Future Phase 2 'informational' tier gets a
// dedicated bucket once seeded.
out.optional.push(r);
}
}
return out;
}
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
const blocks: string[] = [];
if (groups.mandatory.length > 0) {
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
}
if (groups.recommended.length > 0) {
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
}
if (groups.optional.length > 0) {
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
}
if (groups.conditional.length > 0) {
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
}
if (blocks.length === 0) {
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
}
return blocks.join("");
}
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
const items = rows.map((r) => renderRule(r, lang)).join("");
return `
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
<ul class="fristen-overhaul-rule-list">
${items}
</ul>
</div>
`;
}
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
const sel = selections.get(r.rule_id);
const checked = sel ? sel.checked : defaultChecked(r);
const dateOverride = sel?.override;
const computedDate = r.due_date || "";
const effectiveDate = dateOverride || computedDate;
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
const durationPhrase = formatDurationPhrase(r, lang);
const dateCell = r.is_court_set
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
: effectiveDate
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">&mdash;</span>`;
const partyBadge = r.primary_party
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
: "";
const sourceBadge = r.legal_source_display
? r.legal_source_url
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
: r.rule_code
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
: "";
const spawnBadge = r.is_spawn
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
: "";
const condBadge = r.has_condition
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
: "";
const notesHtml = notes
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
: "";
const editBtn = r.is_court_set || r.is_spawn || !computedDate
? ""
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
return `
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}" data-rule-id="${escAttr(r.rule_id)}">
<label class="fristen-overhaul-rule-check">
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
</label>
<div class="fristen-overhaul-rule-body">
<div class="fristen-overhaul-rule-title-row">
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
${spawnBadge}
${condBadge}
</div>
<div class="fristen-overhaul-rule-meta-row">
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
${partyBadge}
${sourceBadge}
</div>
${notesHtml}
</div>
<div class="fristen-overhaul-rule-date-cell">
${dateCell}
${editBtn}
</div>
</li>
`;
}
function renderFooter(): string {
const selectedCount = countSelected();
return `
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
</span>
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
id="fristen-overhaul-write-back"
${selectedCount === 0 ? "disabled" : ""}>
${escHtml(t("deadlines.overhaul.footer.cta"))}
</button>
</footer>
`;
}
// Event wiring --------------------------------------------------------
function wireSurfaceEvents(): void {
// Trigger-date change → re-fetch with new date.
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
if (dateInput && currentResponse) {
dateInput.addEventListener("change", () => {
if (!currentResponse) return;
const newDate = dateInput.value;
if (!newDate) return;
void mountResultView({
eventRef: currentResponse.trigger.code,
triggerDate: newDate,
party: currentResponse.party,
});
});
}
// Checkbox toggles → update selections + footer count.
const root = document.getElementById("fristen-overhaul-root");
if (root) {
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", () => {
const id = cb.dataset.ruleId || "";
const sel = selections.get(id) ?? { checked: cb.checked };
sel.checked = cb.checked;
selections.set(id, sel);
refreshFooterCount();
});
});
// Per-rule date override.
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
btn.addEventListener("click", () => editRuleDate(btn));
});
}
// Write-back CTA.
const cta = document.getElementById("fristen-overhaul-write-back");
if (cta) cta.addEventListener("click", () => void submitWriteBack());
}
function editRuleDate(btn: HTMLButtonElement): void {
const ruleId = btn.dataset.ruleId || "";
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
if (!rule) return;
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
const current = sel.override || rule.due_date || todayIso();
const dateCell = btn.parentElement;
if (!dateCell) return;
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
if (!dateSpan) return;
const input = document.createElement("input");
input.type = "date";
input.value = current;
input.className = "fristen-overhaul-rule-date-input";
dateSpan.replaceWith(input);
btn.disabled = true;
input.focus();
const commit = () => {
const newDate = input.value;
if (newDate && newDate !== current) {
sel.override = newDate;
selections.set(ruleId, sel);
}
renderSurface();
};
input.addEventListener("blur", commit, { once: true });
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
e.preventDefault();
input.blur();
} else if ((e as KeyboardEvent).key === "Escape") {
renderSurface();
}
});
}
function refreshFooterCount(): void {
const countEl = document.getElementById("fristen-overhaul-footer-count");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const n = countSelected();
if (countEl) {
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
}
if (cta) cta.disabled = n === 0;
}
function countSelected(): number {
let n = 0;
if (!currentResponse) return 0;
for (const r of currentResponse.follow_ups) {
if (r.is_court_set) continue;
const sel = selections.get(r.rule_id);
if (sel?.checked) n++;
}
return n;
}
// Write-back ----------------------------------------------------------
async function submitWriteBack(): Promise<void> {
if (!currentResponse) return;
if (!currentProjectId) return;
const msg = document.getElementById("fristen-overhaul-msg");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const lang = getLang();
const deadlines: Array<Record<string, unknown>> = [];
for (const r of currentResponse.follow_ups) {
const sel = selections.get(r.rule_id);
if (!sel?.checked) continue;
if (r.is_court_set) continue;
const dueDate = sel.override || r.due_date;
if (!dueDate) continue;
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
deadlines.push({
title,
rule_code: r.rule_code || undefined,
due_date: dueDate,
original_due_date: r.original_due_date || r.due_date || undefined,
source: "fristenrechner",
rule_id: r.rule_id,
notes: notes || undefined,
audit_reason: auditReason(),
});
}
if (deadlines.length === 0 || !msg || !cta) return;
cta.disabled = true;
msg.textContent = "";
msg.className = "fristen-overhaul-msg";
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deadlines }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = body.error || t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
return;
}
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
msg.className = "fristen-overhaul-msg form-msg-ok";
setTimeout(() => {
if (cta) cta.disabled = false;
}, 1500);
} catch {
msg.textContent = t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
}
}
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
function auditReason(): string {
if (!currentResponse) return "";
const name = currentResponse.trigger.name_de;
const date = currentResponse.trigger_date;
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
}
// Helpers -------------------------------------------------------------
export function defaultChecked(r: FollowUpRule): boolean {
if (r.is_court_set) return false;
if (r.is_spawn) return r.priority === "mandatory";
if (r.has_condition) return false;
return r.priority === "mandatory" || r.priority === "recommended";
}
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
if (!r.duration_value || !r.duration_unit) return "";
const unitDE: Record<string, string> = {
days: "Tage",
months: "Monate",
weeks: "Wochen",
years: "Jahre",
};
const unitEN: Record<string, string> = {
days: "days",
months: "months",
weeks: "weeks",
years: "years",
};
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
return `${r.duration_value} ${u}`;
}
function formatDateForLang(iso: string, lang: "de" | "en"): string {
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
if (!iso || iso.length < 10) return iso;
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return iso;
if (lang === "en") {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const idx = parseInt(m, 10) - 1;
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
return `${parseInt(d, 10)} ${mn} ${y}`;
}
return `${d}.${m}.${y}`;
}
function eventKindIcon(kind?: string): string {
switch (kind) {
case "filing": return "&#128229;"; // inbox/letter
case "hearing": return "&#127963;&#65039;"; // courthouse
case "decision": return "&#9878;&#65039;"; // scales
case "order": return "&#128220;"; // page
default: return "&#128197;"; // calendar
}
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function syncUrlState(eventCode: string, triggerDate: string): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", eventCode);
url.searchParams.set("trigger_date", triggerDate);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}

View File

@@ -30,6 +30,7 @@ import {
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
import { isOverhaulMode, mountResultView } from "./fristenrechner-result";
let lastResponse: DeadlineResponse | null = null;
@@ -113,6 +114,49 @@ onLangChange(() => {
let selectedType = "";
// t-paliad-323 Slice S2 — Fristenrechner overhaul boot. Hides the
// legacy step / pathway shells and mounts the result view. S3+S4 will
// hook entry-mode UIs into this; S2 is deep-link only.
function bootOverhaulMode(): void {
// Hide every legacy section so only the overhaul root is visible.
// The page wrapper (`<main>`, `<section class="tool-page">`, the
// tool-header) stays so the sidebar + title carry through.
const hideIds = [
"fristen-step1",
"fristen-step1-summary",
"fristen-step2",
"fristen-pathway-b",
"fristen-step3a",
"fristen-pathway-a",
];
for (const id of hideIds) {
const el = document.getElementById(id);
if (el) {
el.hidden = true;
el.style.display = "none";
}
}
// S2 deep-link contract: ?overhaul=1&event=<code>&trigger_date=…
// When event is missing, leave the surface empty — S3/S4 will mount
// entry-mode UIs onto this surface in later slices.
const params = new URLSearchParams(window.location.search);
const eventRef = params.get("event") || "";
const triggerDate = params.get("trigger_date") || undefined;
const party = params.get("party") || undefined;
const courtId = params.get("court_id") || undefined;
if (!eventRef) {
const root = document.getElementById("fristen-overhaul-root");
if (root) {
root.hidden = false;
root.innerHTML = `<div class="fristen-overhaul-nudge">${t("deadlines.overhaul.empty")}</div>`;
}
return;
}
void mountResultView({ eventRef, triggerDate, party, courtId });
}
function showStep(n: number) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById(`step-${i}`);
@@ -656,6 +700,20 @@ document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
// t-paliad-323 Slice S2 — Fristenrechner overhaul boot.
// When ?overhaul=1 is set, hide the legacy three-step wizard /
// Pathway A+B shells and mount the new result view in their place.
// Deep-linkable via ?overhaul=1&event=<code>&trigger_date=…&project=…
// (the trigger date defaults to today when omitted). S3 (Mode A
// search) and S4 (Mode B wizard) will land users here once they
// identify a trigger event — for now the surface is reached only
// via deep link, but ?overhaul=1 alone shows the empty shell so
// the path is exercisable end-to-end.
if (isOverhaulMode()) {
bootOverhaulMode();
return;
}
// Proceeding type selection
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
btn.addEventListener("click", () => selectProceeding(btn));

View File

@@ -1010,6 +1010,32 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.save.error": "\u00dcbernahme fehlgeschlagen.",
"deadlines.save.skip_court_set": "Gerichtsbestimmte Termine ohne Datum werden \u00fcbersprungen.",
// Fristenrechner overhaul \u2014 shared result view (S2, design \u00a74).
"deadlines.overhaul.loading": "Folge-Fristen werden geladen\u2026",
"deadlines.overhaul.load_error": "Folge-Fristen konnten nicht geladen werden.",
"deadlines.overhaul.empty": "Keine Folge-Fristen f\u00fcr dieses Ereignis hinterlegt.",
"deadlines.overhaul.trigger.label": "Trigger-Ereignis",
"deadlines.overhaul.trigger.date": "Trigger-Datum:",
"deadlines.overhaul.followups.label": "Folge-Fristen",
"deadlines.overhaul.group.mandatory": "Pflicht",
"deadlines.overhaul.group.recommended": "Empfohlen",
"deadlines.overhaul.group.optional": "Kann (auf Antrag)",
"deadlines.overhaul.group.conditional": "Bedingt",
"deadlines.overhaul.spawn.badge": "\u21f2 neues Verfahren",
"deadlines.overhaul.spawn.tooltip": "Diese Regel leitet ein neues Verfahren ein.",
"deadlines.overhaul.condition.badge": "Nur unter Bedingung",
"deadlines.overhaul.notes.summary": "Hinweis",
"deadlines.overhaul.edit_date.label": "\u270f Datum",
"deadlines.overhaul.edit_date.title": "Datum manuell anpassen",
"deadlines.overhaul.select_rule": "Frist ausw\u00e4hlen",
"deadlines.overhaul.footer.count": "{n} Fristen ausgew\u00e4hlt",
"deadlines.overhaul.footer.cta": "In Akte eintragen",
"deadlines.overhaul.nudge.no_project": "Tipp: W\u00e4hle oben eine Akte, um diese Fristen einzutragen.",
"deadlines.party.claimant": "Kl\u00e4gerseite",
"deadlines.party.defendant": "Beklagtenseite",
"deadlines.party.both": "Beide Seiten",
"deadlines.party.court": "Gericht",
// Office labels (shared)
"office.munich": "M\u00fcnchen",
"office.duesseldorf": "D\u00fcsseldorf",
@@ -4122,6 +4148,32 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.save.error": "Import failed.",
"deadlines.save.skip_court_set": "Court-set entries with no date will be skipped.",
// Fristenrechner overhaul — shared result view (S2, design §4).
"deadlines.overhaul.loading": "Loading follow-up deadlines…",
"deadlines.overhaul.load_error": "Could not load follow-up deadlines.",
"deadlines.overhaul.empty": "No follow-up deadlines configured for this event.",
"deadlines.overhaul.trigger.label": "Trigger event",
"deadlines.overhaul.trigger.date": "Trigger date:",
"deadlines.overhaul.followups.label": "Follow-up deadlines",
"deadlines.overhaul.group.mandatory": "Mandatory",
"deadlines.overhaul.group.recommended": "Recommended",
"deadlines.overhaul.group.optional": "Optional",
"deadlines.overhaul.group.conditional": "Conditional",
"deadlines.overhaul.spawn.badge": "⇲ new proceeding",
"deadlines.overhaul.spawn.tooltip": "This rule initiates a new proceeding.",
"deadlines.overhaul.condition.badge": "Conditional",
"deadlines.overhaul.notes.summary": "Note",
"deadlines.overhaul.edit_date.label": "✏ Date",
"deadlines.overhaul.edit_date.title": "Edit date manually",
"deadlines.overhaul.select_rule": "Select deadline",
"deadlines.overhaul.footer.count": "{n} deadlines selected",
"deadlines.overhaul.footer.cta": "Add to project",
"deadlines.overhaul.nudge.no_project": "Tip: pick a project above to import these deadlines.",
"deadlines.party.claimant": "Claimant",
"deadlines.party.defendant": "Defendant",
"deadlines.party.both": "Both parties",
"deadlines.party.court": "Court",
// Office labels (shared)
"office.munich": "Munich",
"office.duesseldorf": "D\u00fcsseldorf",

View File

@@ -123,6 +123,15 @@ export function renderFristenrechner(): string {
</p>
</div>
{/* t-paliad-323 Slice S2 — overhaul result view mount root.
Hidden by default; the client module shows this and hides
the legacy panels when `?overhaul=1` is present in the
URL. Deep-linkable on its own via
`?overhaul=1&event=<code>&trigger_date=…`. Mode A (S3)
and Mode B wizard (S4) will land users on this surface
once they identify a trigger procedural_event. */}
<div className="fristen-overhaul-root" id="fristen-overhaul-root" hidden></div>
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
Akte (project) that scopes the rest of the flow. Filtered
list of visible projects + "Neue Akte anlegen" link +

View File

@@ -1377,6 +1377,26 @@ export type I18nKey =
| "deadlines.neu.title"
| "deadlines.notes.show"
| "deadlines.optional.badge"
| "deadlines.overhaul.condition.badge"
| "deadlines.overhaul.edit_date.label"
| "deadlines.overhaul.edit_date.title"
| "deadlines.overhaul.empty"
| "deadlines.overhaul.followups.label"
| "deadlines.overhaul.footer.count"
| "deadlines.overhaul.footer.cta"
| "deadlines.overhaul.group.conditional"
| "deadlines.overhaul.group.mandatory"
| "deadlines.overhaul.group.optional"
| "deadlines.overhaul.group.recommended"
| "deadlines.overhaul.load_error"
| "deadlines.overhaul.loading"
| "deadlines.overhaul.notes.summary"
| "deadlines.overhaul.nudge.no_project"
| "deadlines.overhaul.select_rule"
| "deadlines.overhaul.spawn.badge"
| "deadlines.overhaul.spawn.tooltip"
| "deadlines.overhaul.trigger.date"
| "deadlines.overhaul.trigger.label"
| "deadlines.party.both"
| "deadlines.party.both.label"
| "deadlines.party.claimant"

View File

@@ -18886,3 +18886,343 @@ dialog.quick-add-sheet::backdrop {
gap: 0.5rem;
}
}
/* === Fristenrechner overhaul (t-paliad-323 Slice S2) =================
*
* Result-view surface mounted under `?overhaul=1`. Sticky trigger card
* on top, four priority groups of follow-up rules, write-back footer
* conditional on `?project=<uuid>`. See
* docs/design-fristenrechner-overhaul-2026-05-26.md §4.
* ==================================================================== */
.fristen-overhaul-root {
display: block;
margin-top: 1.5rem;
}
.fristen-overhaul-loading,
.fristen-overhaul-error,
.fristen-overhaul-empty,
.fristen-overhaul-nudge {
padding: 0.9rem 1.1rem;
border-radius: 0.6rem;
margin: 0.5rem 0;
background: #f4f4f0;
border: 1px solid #e3e3da;
color: #444;
font-size: 0.95rem;
}
.fristen-overhaul-error {
background: #fde9e7;
border-color: #f0b8b1;
color: #732f25;
}
.fristen-overhaul-nudge {
background: #f8fbe8;
border-color: #d2e08b;
color: #4d5a2a;
}
.fristen-overhaul-trigger {
background: #fff;
border: 1px solid #d8d8cf;
border-radius: 0.8rem;
padding: 1rem 1.2rem;
margin-bottom: 1.2rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
}
.fristen-overhaul-trigger-header {
display: flex;
align-items: center;
gap: 0.7rem;
}
.fristen-overhaul-kind-icon {
font-size: 1.5rem;
line-height: 1;
}
.fristen-overhaul-trigger-title {
margin: 0;
font-size: 1.25rem;
color: #1f1f1f;
}
.fristen-overhaul-trigger-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.9rem;
color: #555;
}
.fristen-overhaul-trigger-code,
.fristen-overhaul-trigger-pt,
.fristen-overhaul-trigger-juris {
padding: 0.15rem 0.55rem;
border-radius: 0.4rem;
background: #f1f1eb;
color: #555;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 0.8rem;
}
.fristen-overhaul-trigger-juris {
background: #d3edb7;
color: #38531a;
font-family: inherit;
font-weight: 600;
}
.fristen-overhaul-trigger-date {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 0.8rem;
}
.fristen-overhaul-trigger-date-label {
font-size: 0.9rem;
color: #555;
}
.fristen-overhaul-trigger-date-input {
padding: 0.35rem 0.55rem;
font-size: 0.95rem;
border: 1px solid #c8c8be;
border-radius: 0.4rem;
background: #fff;
}
.fristen-overhaul-groups {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.fristen-overhaul-group {
background: #fff;
border: 1px solid #e2e2d6;
border-radius: 0.7rem;
padding: 0.9rem 1.1rem;
}
.fristen-overhaul-group--mandatory { border-left: 4px solid #c6f41c; }
.fristen-overhaul-group--recommended { border-left: 4px solid #99c4e3; }
.fristen-overhaul-group--optional { border-left: 4px solid #d4d4cc; }
.fristen-overhaul-group--conditional { border-left: 4px solid #f5b66a; }
.fristen-overhaul-group-title {
margin: 0 0 0.6rem 0;
font-size: 1rem;
color: #2a2a2a;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fristen-overhaul-rule-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.fristen-overhaul-rule {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.7rem;
align-items: start;
padding: 0.5rem 0.6rem;
background: #fafaf6;
border: 1px solid #ececde;
border-radius: 0.5rem;
}
.fristen-overhaul-rule.is-disabled {
opacity: 0.7;
}
.fristen-overhaul-rule-check {
display: flex;
align-items: center;
height: 1.4rem;
cursor: pointer;
}
.fristen-overhaul-rule-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.fristen-overhaul-rule-title-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
}
.fristen-overhaul-rule-title {
font-weight: 600;
color: #1f1f1f;
}
.fristen-overhaul-rule-spawn,
.fristen-overhaul-rule-cond {
font-size: 0.75rem;
padding: 0.05rem 0.45rem;
border-radius: 0.35rem;
background: #f3e5cf;
color: #6e4a1d;
white-space: nowrap;
}
.fristen-overhaul-rule-cond {
background: #fff2d6;
color: #7a570e;
}
.fristen-overhaul-rule-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.85rem;
color: #555;
}
.fristen-overhaul-rule-duration {
color: #2a2a2a;
}
.fristen-overhaul-rule-party {
padding: 0.05rem 0.45rem;
border-radius: 0.35rem;
font-size: 0.75rem;
background: #eef2e3;
color: #4a5d2a;
}
.fristen-overhaul-rule-party--claimant { background: #d2e9ff; color: #1c4567; }
.fristen-overhaul-rule-party--defendant { background: #ffe2d7; color: #6e2c14; }
.fristen-overhaul-rule-party--court { background: #f0e2f7; color: #4f2c66; }
.fristen-overhaul-rule-source {
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 0.8rem;
color: #444;
}
a.fristen-overhaul-rule-source {
color: #2d4f1a;
text-decoration: underline;
text-underline-offset: 2px;
}
.fristen-overhaul-rule-notes {
margin-top: 0.3rem;
font-size: 0.85rem;
color: #555;
}
.fristen-overhaul-rule-notes summary {
cursor: pointer;
color: #666;
}
.fristen-overhaul-rule-date-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
font-size: 0.95rem;
min-width: 6.5rem;
}
.fristen-overhaul-rule-date {
font-weight: 600;
color: #1f1f1f;
}
.fristen-overhaul-rule-date--unknown {
color: #999;
font-weight: 400;
}
.fristen-overhaul-rule-court-set {
color: #6e4a1d;
font-style: italic;
font-size: 0.85rem;
}
.fristen-overhaul-rule-date-input {
padding: 0.2rem 0.4rem;
font-size: 0.95rem;
border: 1px solid #c8c8be;
border-radius: 0.3rem;
background: #fff;
}
.fristen-overhaul-rule-edit-date {
border: 0;
background: transparent;
color: #4a6f1f;
font-size: 0.8rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
border-radius: 0.3rem;
}
.fristen-overhaul-rule-edit-date:hover {
background: #eef4dd;
}
.fristen-overhaul-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.2rem;
padding: 0.9rem 1.1rem;
background: #f7fbe6;
border: 1px solid #d3e08b;
border-radius: 0.7rem;
}
.fristen-overhaul-footer-count {
font-size: 0.95rem;
color: #3d501c;
font-weight: 500;
}
.fristen-overhaul-footer-cta {
/* leans on btn-primary / btn-cta-lime classes from global */
}
.fristen-overhaul-msg {
margin-top: 0.8rem;
padding: 0.6rem 0.9rem;
font-size: 0.9rem;
border-radius: 0.4rem;
}
.fristen-overhaul-msg.form-msg-ok { background: #e7f4d6; color: #3a5113; }
.fristen-overhaul-msg.form-msg-error { background: #fde9e7; color: #732f25; }
@media (max-width: 600px) {
.fristen-overhaul-rule {
grid-template-columns: auto 1fr;
}
.fristen-overhaul-rule-date-cell {
grid-column: 1 / -1;
flex-direction: row;
justify-content: flex-end;
align-items: center;
min-width: 0;
}
}

View File

@@ -0,0 +1,53 @@
-- 153_proceeding_types_kind.down — t-paliad-325 / m/paliad#147
--
-- Best-effort rollback of mig 153. Restores the pre-mig state of
-- paliad.proceeding_types from the same-TX snapshot, drops the kind
-- column, drops the backstop trigger.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 153 down: revert proceeding_types kind discriminator',
true
);
-- ----------------------------------------------------------------
-- 1. Drop the backstop trigger + function.
-- ----------------------------------------------------------------
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
ON paliad.projects;
DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_kind_check();
-- ----------------------------------------------------------------
-- 2. Restore is_active flags from the snapshot. We only touch rows
-- whose is_active value diverged from the snapshot — i.e. the 23
-- rows that mig 153 §4 deactivated.
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types pt
SET is_active = pre.is_active
FROM paliad.proceeding_types_pre_153 pre
WHERE pt.id = pre.id
AND pt.is_active IS DISTINCT FROM pre.is_active;
-- ----------------------------------------------------------------
-- 3. Drop the kind column (cascades the index).
-- ----------------------------------------------------------------
DROP INDEX IF EXISTS paliad.proceeding_types_kind_active_idx;
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS kind;
-- ----------------------------------------------------------------
-- 4. Drop the snapshot table.
-- (The CHECK constraint on the kind column is dropped implicitly
-- when the column is dropped.)
-- ----------------------------------------------------------------
DROP TABLE IF EXISTS paliad.proceeding_types_pre_153;
COMMIT;

View File

@@ -0,0 +1,201 @@
-- 153_proceeding_types_kind — t-paliad-325 / m/paliad#147
--
-- Purpose: tag every paliad.proceeding_types row with a structural
-- classification so the Mode B R3 wizard (Fristenrechner overhaul,
-- m/paliad#146), the projects.proceeding_type_id binding, and the
-- pkg/litigationplanner snapshot can filter to primary proceedings
-- only — separating self-contained matters from CFI phases,
-- in-proceeding side-actions, and cross-cutting RoP/admin rows.
--
-- Design: docs/design-proceeding-types-taxonomy-2026-05-26.md
-- §0§10 (m ratified 2026-05-27 09:52 via 11-question AskUserQuestion
-- batch; "proceed, sure" greenlight at 09:57).
--
-- This mig is purely additive: ALTER TABLE adds the kind column with
-- a safe DEFAULT, UPDATEs reclassify the 23 non-primary rows, and a
-- BEFORE INSERT/UPDATE trigger backstops the new
-- "projects.proceeding_type_id must point at kind='proceeding'"
-- invariant. The 23 rows being reclassified have zero downstream
-- consumers today (0 active sequencing_rules anchor, 0 spawn, 0
-- projects bind, 0 event_category_concepts reference) so no FK
-- reparenting is needed — verified via Supabase MCP 2026-05-27
-- before write.
--
-- Hard constraints honoured (mirrors precedent migs 091/093/095/098/
-- 140/151/152):
-- * No deletions. Non-primary rows flip is_active=false but stay in
-- the table for audit + future re-activation.
-- * Snapshot the affected proceeding_types into
-- paliad.proceeding_types_pre_153 in the same TX.
-- * set_config('paliad.audit_reason') is defensively called even
-- though no audit trigger fires on proceeding_types today; a
-- future audit trigger would inherit the reason automatically.
-- * Idempotent on re-apply — the ADD COLUMN uses IF NOT EXISTS
-- semantics through golang-migrate's tracker (mig only fires
-- once); the UPDATEs only touch rows that match the explicit ID
-- list from the ratified design §3.2 / §10.2.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 153: proceeding_types kind discriminator (m/paliad#147)',
true
);
-- ----------------------------------------------------------------
-- 1. Snapshot the pre-mig state for audit + rollback safety.
-- Mirrors precedent: sequencing_rules_pre_151/_pre_152,
-- procedural_events_pre_151.
-- ----------------------------------------------------------------
CREATE TABLE paliad.proceeding_types_pre_153 AS
SELECT * FROM paliad.proceeding_types;
COMMENT ON TABLE paliad.proceeding_types_pre_153 IS
'Snapshot of paliad.proceeding_types taken in the same TX as '
'mig 153 (kind discriminator). Audit + rollback safety per the '
'precedent set by migs 091/093/095/098/140/151/152. Drop only '
'when the kind taxonomy has held in prod for at least one '
'release cycle and no rollback is anticipated.';
-- ----------------------------------------------------------------
-- 2. Add the kind column.
-- ----------------------------------------------------------------
ALTER TABLE paliad.proceeding_types
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
COMMENT ON COLUMN paliad.proceeding_types.kind IS
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
'proceeding = self-contained matter (own filing + deadline tree); '
'phase = stage inside a primary CFI proceeding; '
'side_action = application/order inside a proceeding; '
'meta = RoP mechanics, court admin, cross-cutting remedies.';
CREATE INDEX proceeding_types_kind_active_idx
ON paliad.proceeding_types(kind, is_active)
WHERE is_active = true;
-- ----------------------------------------------------------------
-- 3. Reclassify the 23 non-primary rows.
-- IDs per ratified design §3.2 / §10.2. m's Q2 carve-out keeps
-- upc.costs.cfi (176) as kind='proceeding' (defaults to that);
-- Q3.b keeps upc.pl.cfi (188) as kind='proceeding' (defaults).
-- ----------------------------------------------------------------
-- 3.1 Phases: 4 rows (Q2 carve-out drops upc.costs.cfi from the original 5).
UPDATE paliad.proceeding_types
SET kind = 'phase'
WHERE id IN (173, 174, 175, 185);
-- 3.2 Side-actions: 10 rows (§0.4 Group C).
UPDATE paliad.proceeding_types
SET kind = 'side_action'
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183);
-- 3.3 Meta / cross-cutting: 9 rows (§0.4 Group D incl. upc.reestablishment.rop).
UPDATE paliad.proceeding_types
SET kind = 'meta'
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169);
-- 3.4 Defensive integrity check — every reclassified ID must have been
-- reached. If the live table drifted between design (2026-05-26)
-- and apply, this raises before the trigger ships.
DO $$
DECLARE
expected int := 23;
actual int;
BEGIN
SELECT COUNT(*) INTO actual
FROM paliad.proceeding_types
WHERE kind <> 'proceeding';
IF actual <> expected THEN
RAISE EXCEPTION
'[mig 153] expected % rows reclassified to non-proceeding kind, found % — '
'live IDs drifted from the design. Abort.',
expected, actual;
END IF;
RAISE NOTICE '[mig 153] reclassified % rows: 4 phase + 10 side_action + 9 meta', actual;
END $$;
-- ----------------------------------------------------------------
-- 4. Per m's Q9: deactivate the non-primary rows so the admin list
-- surfaces only primaries. The kind column carries the semantic
-- info; is_active controls UI visibility. Reversible — flip
-- is_active back on if a row gains corpus.
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types
SET is_active = false
WHERE kind IN ('phase', 'side_action', 'meta');
-- ----------------------------------------------------------------
-- 5. Backstop trigger on projects.proceeding_type_id (§3.3 + Q8).
-- Complements mig 088's category check; rejects any
-- INSERT/UPDATE that would bind a project to a non-proceeding
-- kind. Independent from the category trigger so each invariant
-- can be dropped in isolation.
-- ----------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_kind_check()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_kind text;
BEGIN
IF NEW.proceeding_type_id IS NULL THEN
RETURN NEW;
END IF;
SELECT kind INTO v_kind
FROM paliad.proceeding_types
WHERE id = NEW.proceeding_type_id;
IF v_kind IS NULL THEN
-- FK should have caught this; defensive for any future FK relax.
RAISE EXCEPTION
'paliad.projects.proceeding_type_id = % does not resolve to a '
'proceeding_types row — FK constraint should have caught this.',
NEW.proceeding_type_id;
END IF;
IF v_kind <> 'proceeding' THEN
RAISE EXCEPTION
'paliad.projects.proceeding_type_id must reference a kind=''proceeding'' '
'proceeding_types row (got kind=''%''). '
'Verfahrenstyp muss ein primäres Verfahren sein (kind=''%''). '
'Phasen, Nebenanträge und RoP-Querschnittsregeln sind keine '
'wählbaren Projekt-Verfahrenstypen.',
v_kind, v_kind
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION paliad.projects_proceeding_type_kind_check() IS
'BEFORE INSERT/UPDATE trigger function enforcing the mig 153 '
'invariant: paliad.projects.proceeding_type_id may only '
'reference kind=''proceeding'' proceeding_types rows. NULL is '
'allowed. Complements mig 088''s category check.';
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
ON paliad.projects;
CREATE TRIGGER projects_proceeding_type_kind_check
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
FOR EACH ROW
EXECUTE FUNCTION paliad.projects_proceeding_type_kind_check();
COMMENT ON TRIGGER projects_proceeding_type_kind_check ON paliad.projects IS
'mig 153 (t-paliad-325 / m/paliad#147) runtime guard — rejects '
'any INSERT/UPDATE that would bind a project to a phase/'
'side_action/meta proceeding_types row. The Go service layer '
'also enforces this with a typed error; this trigger is the '
'defence-in-depth backstop.';
COMMIT;

View File

@@ -0,0 +1,65 @@
package handlers
import (
"errors"
"net/http"
"time"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/tools/fristenrechner/follow-ups — given a trigger event and
// a trigger date, return the immediate follow-up sequencing rules with
// their computed due dates (Fristenrechner overhaul S1, design §6.2).
//
// Query params:
// event - procedural_events.code OR procedural_events.id
// (uuid) OR sequencing_rules.id (uuid). Required.
// trigger_date - YYYY-MM-DD. Defaults to today when omitted, so the
// frontend can show a result preview before the user
// commits a date.
// party - "claimant" | "defendant" | "court" | "both".
// Optional; narrows follow-ups by primary_party
// (claimant/defendant filters keep "both" rules
// visible — they're bilateral procedural moves).
// court_id - paliad.courts.id (uuid); selects the holiday
// calendar for date adjustment. Optional.
func handleFristenrechnerFollowUps(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.fristenrechner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
q := r.URL.Query()
eventRef := q.Get("event")
if eventRef == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "event ist erforderlich (procedural_events.code oder id)",
})
return
}
triggerDate := q.Get("trigger_date")
if triggerDate == "" {
triggerDate = time.Now().Format("2006-01-02")
}
resp, err := dbSvc.fristenrechner.LookupFollowUps(
r.Context(),
eventRef,
triggerDate,
q.Get("party"),
q.Get("court_id"),
)
if err != nil {
if errors.Is(err, services.ErrUnknownProceduralEvent) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "Unbekanntes Ereignis: " + eventRef,
})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -32,6 +32,10 @@ import (
// dpma). Trigger pills bypass this filter.
// limit - max cards (default 12, max 30; in browse
// modes default 200, max 500)
// kind - "events" switches to the events-shape
// response (Fristenrechner overhaul S1,
// design §6.1). The default concept-card
// shape is unchanged when kind is empty.
//
// Returns an empty cards array (not 400) when q is empty — that lets
// the frontend boot the search input without a server round-trip.
@@ -42,6 +46,10 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
})
return
}
if r.URL.Query().Get("kind") == "events" {
handleFristenrechnerSearchEvents(w, r)
return
}
q := r.URL.Query().Get("q")
opts := services.SearchOptions{
Party: r.URL.Query().Get("party"),
@@ -60,6 +68,35 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// handleFristenrechnerSearchEvents serves the ?kind=events shape of
// /api/tools/fristenrechner/search (overhaul S1, design §6.1). Returns
// one hit per (procedural_event × proceeding_type) tuple, with a
// follow-up count and a trigram similarity score.
//
// Query params (additive to the legacy search params):
// q - free-text search against name / name_en / code
// jurisdiction - "UPC" | "DE" | "EPA" | "DPMA"
// proc - proceeding_type code
// event_kind - "filing" | "hearing" | "decision" | "order"
// party - primary_party of the anchor rule
// limit - max hits (default 50, max 200)
func handleFristenrechnerSearchEvents(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
opts := services.EventSearchOptions{
Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))),
ProceedingTypeCode: r.URL.Query().Get("proc"),
EventKind: r.URL.Query().Get("event_kind"),
PrimaryParty: r.URL.Query().Get("party"),
Limit: parseLimit(r.URL.Query().Get("limit")),
}
resp, err := dbSvc.deadlineSearch.SearchEvents(r.Context(), q, opts)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Ereignis-Suche fehlgeschlagen: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, resp)
}
// parseCSV splits a comma-separated query-string value into a slice of
// trimmed non-empty entries. Empty input → nil.
func parseCSV(raw string) []string {

View File

@@ -307,6 +307,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/tools/event-trigger", handleEventTriggerCalculate)
protected.HandleFunc("GET /api/tools/courts", handleCourtsList)
protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch)
protected.HandleFunc("GET /api/tools/fristenrechner/follow-ups", handleFristenrechnerFollowUps)
protected.HandleFunc("GET /api/tools/fristenrechner/event-categories", handleFristenrechnerEventCategories)
protected.HandleFunc("GET /downloads", handleDownloadsPage)
protected.HandleFunc("GET /glossary", handleGlossaryPage)

View File

@@ -0,0 +1,404 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// ErrUnknownProceduralEvent is returned by LookupFollowUps when the
// requested procedural_event cannot be resolved (unknown id / unknown
// code / not active+published). Distinct from ErrUnknownTriggerEvent
// (which lives on the legacy Pipeline C / paliad.trigger_events path).
var ErrUnknownProceduralEvent = errors.New("unknown procedural event")
// FollowUpsResponse is the wire shape for GET
// /api/tools/fristenrechner/follow-ups (Fristenrechner overhaul S1,
// design §6.2). Captures the locked trigger event + every immediate
// follow-up rule with its computed due date.
type FollowUpsResponse struct {
Trigger FollowUpTrigger `json:"trigger"`
TriggerDate string `json:"trigger_date"`
Party *string `json:"party,omitempty"`
FollowUps []FollowUpRule `json:"follow_ups"`
}
// FollowUpTrigger is the locked trigger event identity returned by
// LookupFollowUps.
type FollowUpTrigger struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
EventKind *string `json:"event_kind,omitempty"`
ProceedingType EventSearchPT `json:"proceeding_type"`
AnchorRuleID uuid.UUID `json:"anchor_rule_id"`
}
// FollowUpRule is one follow-up deadline returned by LookupFollowUps.
// Carries the rule metadata + the computed due date (or the
// "wird vom Gericht bestimmt" / "abhängig von …" marker for rules whose
// date is undefined).
type FollowUpRule struct {
RuleID uuid.UUID `json:"rule_id"`
EventCode string `json:"event_code"`
TitleDE string `json:"title_de"`
TitleEN string `json:"title_en"`
Priority string `json:"priority"`
PrimaryParty *string `json:"primary_party,omitempty"`
DurationValue *int `json:"duration_value,omitempty"`
DurationUnit *string `json:"duration_unit,omitempty"`
Timing *string `json:"timing,omitempty"`
DueDate string `json:"due_date,omitempty"`
OriginalDueDate string `json:"original_due_date,omitempty"`
WasAdjusted bool `json:"was_adjusted,omitempty"`
IsCourtSet bool `json:"is_court_set"`
IsSpawn bool `json:"is_spawn"`
IsBilateral bool `json:"is_bilateral"`
HasCondition bool `json:"has_condition"`
RuleCode *string `json:"rule_code,omitempty"`
LegalSource *string `json:"legal_source,omitempty"`
LegalSourceDisplay *string `json:"legal_source_display,omitempty"`
LegalSourceURL *string `json:"legal_source_url,omitempty"`
NotesDE *string `json:"notes_de,omitempty"`
NotesEN *string `json:"notes_en,omitempty"`
SpawnLabel *string `json:"spawn_label,omitempty"`
SpawnProceedingCode *string `json:"spawn_proceeding_code,omitempty"`
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
}
// LookupFollowUps returns the follow-up rules anchored on a single
// procedural_event, with computed dates run through the holiday-aware
// litigationplanner.CalculateRule. Identifies the anchor by either the
// procedural_event.id (uuid) or its code; resolves the anchor rule
// (the sequencing_rule with procedural_event_id matching), then walks
// one hop down via parent_id to collect immediate follow-ups.
//
// When party is non-empty, follow-ups are filtered to rules whose
// primary_party matches OR is "both" (so a defendant filter still
// returns bilateral procedural moves like Vertraulichkeitsantrag-
// Erwiderung).
func (s *FristenrechnerService) LookupFollowUps(
ctx context.Context,
eventRef string,
triggerDateStr string,
party string,
courtID string,
) (*FollowUpsResponse, error) {
if eventRef == "" {
return nil, fmt.Errorf("eventRef required")
}
if triggerDateStr == "" {
return nil, fmt.Errorf("triggerDate required")
}
anchor, err := s.resolveTriggerEvent(ctx, eventRef)
if err != nil {
return nil, err
}
resp := &FollowUpsResponse{
Trigger: anchor.Trigger,
TriggerDate: triggerDateStr,
FollowUps: []FollowUpRule{},
}
if party != "" {
p := party
resp.Party = &p
}
// Pull the proceeding_type metadata once so we can pass it
// downstream to populate the trigger card and to seed the
// CalculateRule lookup (which uses RuleID anyway).
rows, err := s.queryFollowUpRows(ctx, anchor.AnchorRuleID, party)
if err != nil {
return nil, err
}
for _, r := range rows {
fr := FollowUpRule{
RuleID: r.RuleID,
EventCode: r.EventCode,
TitleDE: r.NameDE,
TitleEN: r.NameEN,
Priority: r.Priority,
IsCourtSet: r.IsCourtSet,
IsSpawn: r.IsSpawn,
IsBilateral: r.IsBilateral,
HasCondition: r.HasCondition,
}
if r.PrimaryParty.Valid {
v := r.PrimaryParty.String
fr.PrimaryParty = &v
}
if r.DurationValue.Valid {
v := int(r.DurationValue.Int32)
fr.DurationValue = &v
}
if r.DurationUnit.Valid {
v := r.DurationUnit.String
fr.DurationUnit = &v
}
if r.Timing.Valid {
v := r.Timing.String
fr.Timing = &v
}
if r.RuleCode.Valid {
v := r.RuleCode.String
fr.RuleCode = &v
}
if r.LegalSource.Valid {
v := r.LegalSource.String
fr.LegalSource = &v
display := lp.FormatLegalSourceDisplay(v)
if display != "" {
fr.LegalSourceDisplay = &display
}
url := lp.BuildLegalSourceURL(v)
if url != "" {
fr.LegalSourceURL = &url
}
}
if r.NotesDE.Valid {
v := r.NotesDE.String
fr.NotesDE = &v
}
if r.NotesEN.Valid {
v := r.NotesEN.String
fr.NotesEN = &v
}
if r.SpawnLabel.Valid {
v := r.SpawnLabel.String
fr.SpawnLabel = &v
}
if r.SpawnProceedingCode.Valid {
v := r.SpawnProceedingCode.String
fr.SpawnProceedingCode = &v
}
if r.ConceptID != nil {
fr.ConceptID = r.ConceptID
}
// Skip date computation for court-set / spawn rules — they don't
// project a calendar date here.
if !r.IsCourtSet && !r.IsSpawn {
calc, err := s.CalculateRule(ctx, lp.CalcRuleParams{
RuleID: r.RuleID.String(),
TriggerDate: triggerDateStr,
CourtID: courtID,
})
if err == nil {
fr.DueDate = calc.DueDate
fr.OriginalDueDate = calc.OriginalDate
fr.WasAdjusted = calc.WasAdjusted
}
// On error: leave the date fields empty — the frontend
// already handles missing dates as "abhängig von ..." style
// markers and a single bad rule shouldn't 500 the whole
// follow-up list.
}
resp.FollowUps = append(resp.FollowUps, fr)
}
return resp, nil
}
// anchorResolution carries the resolver output: the trigger card metadata
// plus the anchor rule id (the sequencing_rule.id whose
// procedural_event_id equals the trigger event).
type anchorResolution struct {
Trigger FollowUpTrigger
AnchorRuleID uuid.UUID
}
// resolveTriggerEvent looks up the trigger event by either uuid or code.
// Returns ErrUnknownTriggerEvent when no published+active anchor row
// matches.
func (s *FristenrechnerService) resolveTriggerEvent(ctx context.Context, ref string) (*anchorResolution, error) {
// Try uuid first; fall back to code lookup.
type row struct {
EventID uuid.UUID `db:"event_id"`
Code string `db:"code"`
NameDE string `db:"name_de"`
NameEN string `db:"name_en"`
EventKind sql.NullString `db:"event_kind"`
AnchorRuleID uuid.UUID `db:"anchor_rule_id"`
PTID int `db:"pt_id"`
PTCode string `db:"pt_code"`
PTNameDE string `db:"pt_name_de"`
PTNameEN string `db:"pt_name_en"`
PTJurisdiction sql.NullString `db:"pt_jurisdiction"`
}
var r row
queryBase := `
SELECT pe.id AS event_id,
pe.code,
pe.name AS name_de,
pe.name_en,
pe.event_kind,
sr.id AS anchor_rule_id,
pt.id AS pt_id,
pt.code AS pt_code,
pt.name AS pt_name_de,
pt.name_en AS pt_name_en,
pt.jurisdiction AS pt_jurisdiction
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
WHERE sr.is_active = true
AND sr.lifecycle_state = 'published'
AND pe.is_active = true
AND pe.lifecycle_state = 'published'
AND pt.is_active = true
AND %s
ORDER BY pt.sort_order
LIMIT 1`
if id, err := uuid.Parse(ref); err == nil {
// Treat as a procedural_event id OR a sequencing_rule id (the
// frontend may pass either — search returns event id but a
// concept-card-derived flow may pass the rule id).
err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "(pe.id = $1 OR sr.id = $1)"), id)
if err == nil {
goto found
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("resolve trigger event by id: %w", err)
}
// fall through to code lookup
}
{
err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "pe.code = $1"), ref)
if err == nil {
goto found
}
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUnknownProceduralEvent
}
return nil, fmt.Errorf("resolve trigger event by code: %w", err)
}
found:
res := &anchorResolution{
AnchorRuleID: r.AnchorRuleID,
Trigger: FollowUpTrigger{
ID: r.EventID,
Code: r.Code,
NameDE: r.NameDE,
NameEN: r.NameEN,
AnchorRuleID: r.AnchorRuleID,
ProceedingType: EventSearchPT{
ID: r.PTID,
Code: r.PTCode,
NameDE: r.PTNameDE,
NameEN: r.PTNameEN,
},
},
}
if r.EventKind.Valid {
v := r.EventKind.String
res.Trigger.EventKind = &v
}
if r.PTJurisdiction.Valid {
v := r.PTJurisdiction.String
res.Trigger.ProceedingType.Jurisdiction = &v
}
return res, nil
}
// followUpRow is the joined SELECT shape for follow-up rules.
type followUpRow struct {
RuleID uuid.UUID `db:"rule_id"`
EventCode string `db:"event_code"`
NameDE string `db:"name_de"`
NameEN string `db:"name_en"`
Priority string `db:"priority"`
PrimaryParty sql.NullString `db:"primary_party"`
DurationValue sql.NullInt32 `db:"duration_value"`
DurationUnit sql.NullString `db:"duration_unit"`
Timing sql.NullString `db:"timing"`
IsCourtSet bool `db:"is_court_set"`
IsSpawn bool `db:"is_spawn"`
IsBilateral bool `db:"is_bilateral"`
HasCondition bool `db:"has_condition"`
RuleCode sql.NullString `db:"rule_code"`
LegalSource sql.NullString `db:"legal_source"`
NotesDE sql.NullString `db:"notes_de"`
NotesEN sql.NullString `db:"notes_en"`
SpawnLabel sql.NullString `db:"spawn_label"`
SpawnProceedingCode sql.NullString `db:"spawn_proceeding_code"`
ConceptID *uuid.UUID `db:"concept_id"`
SequenceOrder int `db:"sequence_order"`
}
// queryFollowUpRows pulls the immediate-children rules of an anchor.
// Party filter is inclusive of "both" so bilateral moves stay visible
// when the user picks claimant or defendant.
func (s *FristenrechnerService) queryFollowUpRows(
ctx context.Context,
anchorRuleID uuid.UUID,
party string,
) ([]followUpRow, error) {
where := []string{
"sr.parent_id = $1",
"sr.is_active = true",
"sr.lifecycle_state = 'published'",
"pe.is_active = true",
"pe.lifecycle_state = 'published'",
}
args := []any{anchorRuleID}
if party == "claimant" || party == "defendant" {
args = append(args, party)
where = append(where, fmt.Sprintf(
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
len(args)))
} else if party != "" {
// "court" / "both" — exact match
args = append(args, party)
where = append(where, fmt.Sprintf("sr.primary_party = $%d", len(args)))
}
query := `
SELECT sr.id AS rule_id,
pe.code AS event_code,
pe.name AS name_de,
pe.name_en,
sr.priority,
sr.primary_party,
sr.duration_value,
sr.duration_unit,
sr.timing,
sr.is_court_set,
sr.is_spawn,
sr.is_bilateral,
(sr.condition_expr IS NOT NULL) AS has_condition,
sr.rule_code,
ls.citation AS legal_source,
sr.deadline_notes AS notes_de,
sr.deadline_notes_en AS notes_en,
sr.spawn_label,
spt.code AS spawn_proceeding_code,
pe.concept_id,
sr.sequence_order
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id
WHERE ` + strings.Join(where, "\n AND ") + `
ORDER BY sr.sequence_order, pe.code`
var rows []followUpRow
if err := s.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("load follow-up rows: %w", err)
}
return rows, nil
}

View File

@@ -0,0 +1,205 @@
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestSearchEvents covers the ?kind=events response shape for the
// Fristenrechner overhaul S1 (design §6.1). Verified against live data:
// "Klageerhebung" must return upc.inf.cfi.soc (the canonical SoC
// procedural event) as the top hit, with the proceeding metadata
// populated and a non-zero follow_up_count.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB
// tests in this package.
func TestSearchEvents(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
svc := NewDeadlineSearchService(pool)
t.Run("Klageerhebung returns upc.inf.cfi.soc with follow-ups", func(t *testing.T) {
resp, err := svc.SearchEvents(ctx, "Klageerhebung", EventSearchOptions{Limit: 30})
if err != nil {
t.Fatalf("search events: %v", err)
}
if len(resp.Events) == 0 {
t.Fatalf("no events returned for Klageerhebung")
}
var soc *EventSearchHit
for i := range resp.Events {
if resp.Events[i].Code == "upc.inf.cfi.soc" {
soc = &resp.Events[i]
break
}
}
if soc == nil {
t.Fatalf("upc.inf.cfi.soc not in event hits (got %d hits)", len(resp.Events))
}
if soc.NameDE == "" {
t.Errorf("expected name_de populated, got empty")
}
if soc.ProceedingType.Code != "upc.inf.cfi" {
t.Errorf("expected proceeding upc.inf.cfi, got %q", soc.ProceedingType.Code)
}
if soc.FollowUpCount <= 0 {
t.Errorf("expected follow_up_count > 0 for SoC, got %d", soc.FollowUpCount)
}
if soc.EventKind == nil || *soc.EventKind != "filing" {
gotKind := "<nil>"
if soc.EventKind != nil {
gotKind = *soc.EventKind
}
t.Errorf("expected event_kind=filing, got %q", gotKind)
}
})
t.Run("jurisdiction filter narrows to UPC", func(t *testing.T) {
resp, err := svc.SearchEvents(ctx, "", EventSearchOptions{
Jurisdiction: "UPC",
Limit: 200,
})
if err != nil {
t.Fatalf("search events UPC: %v", err)
}
if len(resp.Events) == 0 {
t.Fatalf("expected UPC events, got 0")
}
for _, e := range resp.Events {
if e.ProceedingType.Jurisdiction == nil || *e.ProceedingType.Jurisdiction != "UPC" {
gotJ := "<nil>"
if e.ProceedingType.Jurisdiction != nil {
gotJ = *e.ProceedingType.Jurisdiction
}
t.Errorf("non-UPC event leaked: %s (jurisdiction=%q)", e.Code, gotJ)
}
}
})
t.Run("event_kind=filing narrows by kind", func(t *testing.T) {
resp, err := svc.SearchEvents(ctx, "", EventSearchOptions{
EventKind: "filing",
Limit: 200,
})
if err != nil {
t.Fatalf("search events filing: %v", err)
}
if len(resp.Events) == 0 {
t.Fatalf("expected filing events, got 0")
}
for _, e := range resp.Events {
if e.EventKind == nil || *e.EventKind != "filing" {
gotKind := "<nil>"
if e.EventKind != nil {
gotKind = *e.EventKind
}
t.Errorf("non-filing event leaked: %s (event_kind=%q)", e.Code, gotKind)
}
}
})
}
// TestLookupFollowUps covers the GET /api/tools/fristenrechner/follow-ups
// endpoint contract (overhaul S1, design §6.2). Verified against live
// data: looking up upc.inf.cfi.soc returns the four canonical follow-up
// rules (Klageerwiderung, CCR, Einspruch, Vertraulichkeits-Erwiderung),
// each with a computed due date or court-set marker.
func TestLookupFollowUps(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
holidays := NewHolidayService(pool)
courts := NewCourtService(pool)
rules := NewDeadlineRuleService(pool)
fr := NewFristenrechnerService(rules, holidays, courts)
t.Run("SoC returns follow-ups with computed dates", func(t *testing.T) {
resp, err := fr.LookupFollowUps(ctx, "upc.inf.cfi.soc", "2026-05-20", "", "")
if err != nil {
t.Fatalf("lookup follow-ups: %v", err)
}
if resp.Trigger.Code != "upc.inf.cfi.soc" {
t.Errorf("trigger code = %q, want upc.inf.cfi.soc", resp.Trigger.Code)
}
if len(resp.FollowUps) == 0 {
t.Fatalf("expected follow-ups, got 0")
}
// At least the Klageerwiderung (sod) should be present and have a date.
var sod *FollowUpRule
for i := range resp.FollowUps {
if resp.FollowUps[i].EventCode == "upc.inf.cfi.sod" {
sod = &resp.FollowUps[i]
break
}
}
if sod == nil {
t.Fatalf("Klageerwiderung (upc.inf.cfi.sod) not in follow-ups")
}
if sod.DueDate == "" {
t.Errorf("expected due_date populated for sod, got empty")
}
if sod.Priority != "mandatory" {
t.Errorf("expected priority=mandatory for sod, got %q", sod.Priority)
}
// 3 months after 2026-05-20 (then weekend-adjusted) — sanity check
// only that something resembling 2026-08 came back.
if len(sod.DueDate) < 7 || sod.DueDate[:7] != "2026-08" {
t.Errorf("expected due_date in 2026-08, got %q", sod.DueDate)
}
})
t.Run("party=defendant narrows but keeps bilateral rules", func(t *testing.T) {
resp, err := fr.LookupFollowUps(ctx, "upc.inf.cfi.soc", "2026-05-20", "defendant", "")
if err != nil {
t.Fatalf("lookup follow-ups (defendant): %v", err)
}
if len(resp.FollowUps) == 0 {
t.Fatalf("expected defendant follow-ups, got 0")
}
for _, r := range resp.FollowUps {
if r.PrimaryParty == nil {
continue
}
p := *r.PrimaryParty
if p == "claimant" {
t.Errorf("claimant-only rule leaked under defendant filter: %s", r.EventCode)
}
}
})
t.Run("unknown event returns ErrUnknownProceduralEvent", func(t *testing.T) {
_, err := fr.LookupFollowUps(ctx, "no.such.event", "2026-05-20", "", "")
if err != ErrUnknownProceduralEvent {
t.Errorf("expected ErrUnknownProceduralEvent, got %v", err)
}
})
}

View File

@@ -0,0 +1,257 @@
package services
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/google/uuid"
)
// EventSearchHit is one ranked hit in the events-shape search response.
// Returned by FristenrechnerService.SearchEvents.
//
// One hit per (procedural_event, proceeding_type) tuple: a single event
// can appear in multiple proceedings (the data carries handful of
// procedural_event rows whose code is null.* and that are anchored by
// rules in different proceedings — those legacy stragglers surface as
// multiple hits, one per proceeding context).
type EventSearchHit struct {
EventID uuid.UUID `json:"id"`
Code string `json:"code"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
EventKind *string `json:"event_kind,omitempty"`
Description *string `json:"description,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
ProceedingType EventSearchPT `json:"proceeding_type"`
AnchorRuleID uuid.UUID `json:"anchor_rule_id"`
FollowUpCount int `json:"follow_up_count"`
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
Score float64 `json:"score"`
}
// EventSearchPT is the proceeding-type slice embedded in an EventSearchHit.
type EventSearchPT struct {
ID int `json:"id"`
Code string `json:"code"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
Jurisdiction *string `json:"jurisdiction,omitempty"`
}
// EventSearchOptions is the filter set for SearchEvents. Empty values
// mean "no narrowing on this axis".
type EventSearchOptions struct {
// Jurisdiction filters by proceeding_types.jurisdiction
// ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any.
Jurisdiction string
// ProceedingTypeCode narrows to one proceeding. Empty = any.
ProceedingTypeCode string
// EventKind filters by procedural_events.event_kind
// ("filing" | "hearing" | "decision" | "order"). Empty = any.
EventKind string
// PrimaryParty narrows by the anchor rule's primary_party
// ("claimant" | "defendant" | "court" | "both"). Empty = any.
PrimaryParty string
// Limit caps the result set; defaults to 50, max 200.
Limit int
}
// EventSearchResponse is the wire shape for ?kind=events on the
// /api/tools/fristenrechner/search endpoint (design §6.1).
type EventSearchResponse struct {
Query string `json:"query"`
Filters EventSearchFilters `json:"filters"`
Events []EventSearchHit `json:"events"`
Total int `json:"total"`
}
// EventSearchFilters is the filter echo returned to the client.
type EventSearchFilters struct {
Jurisdiction *string `json:"jurisdiction"`
ProceedingTypeCode *string `json:"proceeding_type_code"`
EventKind *string `json:"event_kind"`
PrimaryParty *string `json:"primary_party"`
}
// SearchEvents implements the ?kind=events response shape (Fristenrechner
// overhaul S1, design §6.1). Returns one hit per (procedural_event ×
// proceeding_type) tuple, ranked by trigram similarity against name /
// name_en / code. Empty q returns the unranked catalog filtered by the
// supplied facets.
func (s *DeadlineSearchService) SearchEvents(ctx context.Context, q string, opts EventSearchOptions) (*EventSearchResponse, error) {
limit := opts.Limit
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
qNorm := normalizeQuery(q)
resp := &EventSearchResponse{
Query: q,
Filters: buildEventFilters(opts),
Events: []EventSearchHit{},
}
where := []string{
"sr.is_active = true",
"sr.lifecycle_state = 'published'",
"pe.is_active = true",
"pe.lifecycle_state = 'published'",
"pt.is_active = true",
}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
where = append(where, fmt.Sprintf(clause, len(args)))
}
if opts.Jurisdiction != "" {
add("pt.jurisdiction = $%d", opts.Jurisdiction)
}
if opts.ProceedingTypeCode != "" {
add("pt.code = $%d", opts.ProceedingTypeCode)
}
if opts.EventKind != "" {
add("pe.event_kind = $%d", opts.EventKind)
}
if opts.PrimaryParty != "" {
add("sr.primary_party = $%d", opts.PrimaryParty)
}
// Trigram score over (name || name_en || code). Empty query collapses
// the score to 0 — keeps the SQL identical regardless of input mode.
scoreExpr := "0::float8"
if qNorm != "" {
args = append(args, qNorm)
scoreExpr = fmt.Sprintf(
`GREATEST(similarity(pe.name, $%[1]d), similarity(pe.name_en, $%[1]d), similarity(pe.code, $%[1]d))`,
len(args))
// Drop hits with zero similarity so a typo doesn't return the
// whole catalog ranked at 0.
where = append(where, fmt.Sprintf(
`(pe.name %% $%[1]d OR pe.name_en %% $%[1]d OR pe.code %% $%[1]d)`,
len(args)))
}
// follow_up_count: rules whose parent_id points at this anchor rule.
// Computed via correlated subquery; cheap at the 231-row scale.
query := `
SELECT pe.id AS event_id,
pe.code,
pe.name AS name_de,
pe.name_en,
pe.event_kind,
pe.description,
sr.primary_party,
pe.concept_id,
sr.id AS anchor_rule_id,
pt.id AS pt_id,
pt.code AS pt_code,
pt.name AS pt_name_de,
pt.name_en AS pt_name_en,
pt.jurisdiction AS pt_jurisdiction,
(SELECT COUNT(*)::int
FROM paliad.sequencing_rules child
WHERE child.parent_id = sr.id
AND child.is_active = true
AND child.lifecycle_state = 'published') AS follow_up_count,
` + scoreExpr + ` AS score
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
WHERE ` + strings.Join(where, "\n AND ") + `
ORDER BY score DESC, pt.sort_order, pe.code
LIMIT $` + fmt.Sprintf("%d", len(args)+1)
args = append(args, limit)
type row struct {
EventID uuid.UUID `db:"event_id"`
Code string `db:"code"`
NameDE string `db:"name_de"`
NameEN string `db:"name_en"`
EventKind sql.NullString `db:"event_kind"`
Description sql.NullString `db:"description"`
PrimaryParty sql.NullString `db:"primary_party"`
ConceptID *uuid.UUID `db:"concept_id"`
AnchorRuleID uuid.UUID `db:"anchor_rule_id"`
PTID int `db:"pt_id"`
PTCode string `db:"pt_code"`
PTNameDE string `db:"pt_name_de"`
PTNameEN string `db:"pt_name_en"`
PTJurisdiction sql.NullString `db:"pt_jurisdiction"`
FollowUpCount int `db:"follow_up_count"`
Score float64 `db:"score"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("search events: %w", err)
}
hits := make([]EventSearchHit, 0, len(rows))
for _, r := range rows {
hit := EventSearchHit{
EventID: r.EventID,
Code: r.Code,
NameDE: r.NameDE,
NameEN: r.NameEN,
AnchorRuleID: r.AnchorRuleID,
FollowUpCount: r.FollowUpCount,
ConceptID: r.ConceptID,
Score: r.Score,
ProceedingType: EventSearchPT{
ID: r.PTID,
Code: r.PTCode,
NameDE: r.PTNameDE,
NameEN: r.PTNameEN,
},
}
if r.EventKind.Valid {
v := r.EventKind.String
hit.EventKind = &v
}
if r.Description.Valid {
v := r.Description.String
hit.Description = &v
}
if r.PrimaryParty.Valid {
v := r.PrimaryParty.String
hit.PrimaryParty = &v
}
if r.PTJurisdiction.Valid {
v := r.PTJurisdiction.String
hit.ProceedingType.Jurisdiction = &v
}
hits = append(hits, hit)
}
resp.Events = hits
resp.Total = len(hits)
return resp, nil
}
func buildEventFilters(opts EventSearchOptions) EventSearchFilters {
f := EventSearchFilters{}
if opts.Jurisdiction != "" {
v := opts.Jurisdiction
f.Jurisdiction = &v
}
if opts.ProceedingTypeCode != "" {
v := opts.ProceedingTypeCode
f.ProceedingTypeCode = &v
}
if opts.EventKind != "" {
v := opts.EventKind
f.EventKind = &v
}
if opts.PrimaryParty != "" {
v := opts.PrimaryParty
f.PrimaryParty = &v
}
return f
}

View File

@@ -58,6 +58,14 @@ var (
// surface this as a 400 with a bilingual friendly message; the
// matching DB trigger (mig 088) is the defence-in-depth backstop.
ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row")
// ErrInvalidProceedingTypeKind signals that the caller supplied a
// proceeding_type_id pointing at a non-primary row — i.e. a
// phase/side_action/meta row, or an inactive row. Mig 153
// (t-paliad-325, design §1) carved the taxonomy so only
// kind='proceeding' AND is_active=true rows may bind to a
// project. Handlers surface this as a 400; the matching DB
// trigger (mig 153) is the defence-in-depth backstop.
ErrInvalidProceedingTypeKind = errors.New("proceeding_type_id must reference an active kind='proceeding' proceeding_types row")
)
// ProjectType values enumerated on the projects.type CHECK constraint.
@@ -1165,29 +1173,47 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
return s.GetByID(ctx, userID, id)
}
// validateProceedingTypeCategory enforces the Phase 3 Slice 5 invariant
// (t-paliad-186, design §3.F + m's Q2 ruling): a project may only bind
// to a fristenrechner-category proceeding_types row. NULL passes
// through; the matching DB trigger (mig 088) is the defence-in-depth
// backstop should this slip somehow.
// validateProceedingTypeCategory enforces the project-binding invariants
// on paliad.projects.proceeding_type_id:
//
// Surfaces ErrInvalidProceedingTypeCategory so handlers can map to a
// 400 with a bilingual user-facing message.
// 1. Phase 3 Slice 5 (t-paliad-186, design §3.F): row must be
// category='fristenrechner'. DB-side backstop: mig 088 trigger.
// Surfaces ErrInvalidProceedingTypeCategory.
//
// 2. Mig 153 (t-paliad-325, design §1 + m's Q8): row must be
// kind='proceeding' AND is_active=true. DB-side backstop: mig 153
// trigger. Surfaces ErrInvalidProceedingTypeKind. Rejects phase /
// side_action / meta rows and any deactivated row.
//
// NULL passes through. The Go layer fires first so handlers get typed
// errors; the DB triggers catch any writer that bypasses the service.
func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error {
if ptID == nil {
return nil
}
var category sql.NullString
if err := s.db.GetContext(ctx, &category,
`SELECT category FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
var row struct {
Category sql.NullString `db:"category"`
Kind sql.NullString `db:"kind"`
IsActive bool `db:"is_active"`
}
if err := s.db.GetContext(ctx, &row,
`SELECT category, kind, is_active FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID)
}
return fmt.Errorf("lookup proceeding_type category: %w", err)
return fmt.Errorf("lookup proceeding_type: %w", err)
}
if !category.Valid || category.String != "fristenrechner" {
if !row.Category.Valid || row.Category.String != "fristenrechner" {
return fmt.Errorf("%w: proceeding_type_id=%d has category=%q",
ErrInvalidProceedingTypeCategory, *ptID, category.String)
ErrInvalidProceedingTypeCategory, *ptID, row.Category.String)
}
if !row.Kind.Valid || row.Kind.String != "proceeding" {
return fmt.Errorf("%w: proceeding_type_id=%d has kind=%q",
ErrInvalidProceedingTypeKind, *ptID, row.Kind.String)
}
if !row.IsActive {
return fmt.Errorf("%w: proceeding_type_id=%d is inactive",
ErrInvalidProceedingTypeKind, *ptID)
}
return nil
}

View File

@@ -163,6 +163,162 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
}
}
// TestProjectService_ProceedingTypeKindGuard exercises the mig 153
// (t-paliad-325 / m/paliad#147) "kind='proceeding' only" invariant on
// paliad.projects.proceeding_type_id from three angles:
//
// 1. ProjectService.Create returns ErrInvalidProceedingTypeKind when
// handed an id pointing at a kind='phase' / 'side_action' / 'meta'
// row (the Go service guard fires before the DB trigger).
//
// 2. ProjectService.Create returns ErrInvalidProceedingTypeKind when
// handed an id pointing at a row with is_active=false (mig 153 §4
// deactivated all non-primary rows so this is the same set of IDs;
// the test still independently asserts the is_active branch by
// re-activating a phase row inside the test and confirming the kind
// check still fires).
//
// 3. The mig 153 backstop trigger rejects a raw INSERT that bypasses
// the Go service layer (defence-in-depth). Bypasses mig 088's
// category trigger by also picking a fristenrechner-category row.
//
// 4. Passing a kind='proceeding' active id (upc.inf.cfi) still
// succeeds — proves the new guard doesn't break the happy path.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the rest of this
// file.
func TestProjectService_ProceedingTypeKindGuard(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
// A row that is fristenrechner-category but kind != 'proceeding'.
// Picks the first phase row by id (deterministic). Falls back to any
// non-proceeding kind if no phase rows are present (post-data-drift
// hardening).
var phaseID int
if err := pool.GetContext(ctx, &phaseID,
`SELECT id FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND kind <> 'proceeding'
ORDER BY (kind = 'phase') DESC, id
LIMIT 1`); err != nil {
t.Fatalf("look up non-proceeding kind id: %v", err)
}
// A primary id for the happy-path case + raw-INSERT control.
var proceedingID int
if err := pool.GetContext(ctx, &proceedingID,
`SELECT id FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND kind = 'proceeding'
AND is_active = true AND code = $1`,
CodeUPCInfringement); err != nil {
t.Fatalf("look up %s id: %v", CodeUPCInfringement, err)
}
users := NewUserService(pool)
svc := NewProjectService(pool, users)
userID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE created_by = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'mig153-guard-test@hlc.com')`,
userID); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
VALUES ($1, 'mig153-guard-test@hlc.com', 'Mig153 Guard', 'munich', 'associate', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// 1. Non-proceeding kind id → ErrInvalidProceedingTypeKind from the
// service guard. (The row is also is_active=false post-mig-153,
// but the kind check fires first.)
_, err = svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeProject,
Title: "Mig 153 — non-proceeding-kind reject",
ProceedingTypeID: &phaseID,
})
if err == nil {
t.Error("Create with kind!=proceeding proceeding_type_id should fail, but succeeded")
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
}
// 2. Re-activate the phase row in a savepoint so the kind check
// still fires (proves the kind branch isn't shadowed by the
// is_active branch).
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.proceeding_types SET is_active = true WHERE id = $1`, phaseID); err != nil {
t.Fatalf("re-activate phase row: %v", err)
}
t.Cleanup(func() {
pool.ExecContext(ctx,
`UPDATE paliad.proceeding_types SET is_active = false WHERE id = $1`, phaseID)
})
_, err = svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeProject,
Title: "Mig 153 — active phase row still rejects on kind",
ProceedingTypeID: &phaseID,
})
if err == nil {
t.Error("Create with active kind=phase row should still fail on kind check; got nil")
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
}
// 3. mig 153 trigger — raw INSERT bypassing Go service must raise.
// We use the active phase row (still re-activated from step 2)
// so we don't trip mig 088's category check first. Both triggers
// are independent; mig 153's must fire on a category=fristenrechner
// kind!=proceeding row.
rawID := uuid.New()
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, rawID)
_, err = pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
proceeding_type_id, metadata, created_at, updated_at)
VALUES ($1, 'project', NULL, $1::text, 'Mig 153 — trigger bypass', 'active', $2,
$3, '{}'::jsonb, now(), now())`,
rawID, userID, phaseID)
if err == nil {
t.Error("raw INSERT with kind!=proceeding proceeding_type_id should have raised; got nil")
}
// 4. Happy path: kind='proceeding' active id → success.
created, err := svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeProject,
Title: "Mig 153 — primary proceeding accept",
ProceedingTypeID: &proceedingID,
})
if err != nil {
t.Fatalf("Create with kind=proceeding proceeding_type_id: %v", err)
}
if created.ProceedingTypeID == nil || *created.ProceedingTypeID != proceedingID {
t.Errorf("created proceeding_type_id = %v, want %d", created.ProceedingTypeID, proceedingID)
}
}
// TestProjectService_InstanceLevel_Roundtrip covers the Phase 3 Slice 8
// (t-paliad-189) instance_level data path: Create + Update both accept
// the four allowed shapes (first / appeal / cassation / NULL) and reject