diff --git a/docs/assessment-deadline-system-2026-05-27.md b/docs/assessment-deadline-system-2026-05-27.md new file mode 100644 index 0000000..cdc102d --- /dev/null +++ b/docs/assessment-deadline-system-2026-05-27.md @@ -0,0 +1,738 @@ +# Assessment — Deadline + Procedural-Events System + +**Phase 1 of RFC m/paliad#149.** Read-only audit of every consumer of +`paliad.sequencing_rules` + `paliad.procedural_events` + the legacy +`paliad.trigger_events`, the corpus they project, and the surfaces that +read them. + +- Author: athena (consultant role) +- Date: 2026-05-27 +- Live data: youpc Supabase (`paliad` schema), counts captured during the + audit window (mig 153 applied). +- Scope: assessment only. No design proposals; no schema sketches; no + recommendations on column shape. Phase 2 (inventor) decides those. + +--- + +## 0. Headline numbers + +| Bucket | Total | Active + published | Notes | +|---|--:|--:|---| +| `procedural_events` | 236 | 222 | 5 drafts, 9 archived/inactive | +| `sequencing_rules` | 236 | 226 | 1:1 row-mirror with events (mig 136 + 140) | +| `trigger_events` (legacy) | 110 | — | bigint-keyed catalog; lives parallel to events | +| `proceeding_types` | 50 | 23 kind=`proceeding`; 0 active in kind=`phase`/`side_action`/`meta` (mig 153 flipped them off) | + +Rules-corpus shape (active + published, 226 rows): + +| Classification | Rows | +|---|--:| +| Parent only (chain-linked) | 105 | +| Both parent + legacy trigger | 2 | +| Legacy `trigger_event_id` only — `proceeding_type_id IS NULL` | **73** | +| Neither (root) — `proceeding_type_id` set | 46 | + +Other corpus signals: + +- `condition_expr` populated: 18 rules. Three distinct keys: `flag` (14), + `op` + `args` (4 each — always nested AND). +- `is_spawn = true`: 4 rules. All four point at the **inactive** + `upc.apl.merits` (id=11). The active appeal type is id=160 + (`upc.apl.unified`). See risk R3. +- `is_court_set = true`: 46 rules. +- `is_bilateral = true`: 4 rules. +- `choices_offered` populated: 28 rules. Three shapes: + `{appellant:[…]}` (20), `{skip:[…]}` (6), `{include_ccr:[…]}` (2). +- `applies_to_target` populated: 16 rules. +- 67 distinct events act as chain-anchors (= parent of ≥1 active rule). + That is the *derived* trigger set today. +- `paliad.project_event_choices`: schema present, **0 rows** live. +- `paliad.scenarios` (mig 145): table created, **0 rows**. + `paliad.projects.active_scenario_id`: **0/18 projects** populated. + +A more granular per-proceeding-type breakdown is in §4. + +--- + +## 1. Audit — consumers of `sequencing_rules` + `procedural_events` + +Every read site, by surface. File paths are repo-relative. + +### 1.1 Direct services + +| Service | File | What it reads | Surface(s) it backs | +|---|---|---|---| +| `DeadlineRuleService` | `internal/services/deadline_rule_service.go:14-365` | `paliad.deadline_rules_unified` view (sequencing_rules + procedural_events + legal_sources), + `paliad.trigger_events` for parent-chain labels (`:226-285`) | Admin rules list/editor, Fristenrechner result panel | +| `FristenrechnerService` | `internal/services/fristenrechner.go:115-172,1-700+` | `sequencing_rules` + `procedural_events` (proceeding-type catalog; `EXISTS` over rules); scenarios table (`:583-627`) | `/api/tools/fristenrechner` (Mode A + Mode B + Mode C) | +| `FristenrechnerService.LookupFollowUps` | `internal/services/fristenrechner_followups.go:87-403` | resolves anchor by `pe.id`/`pe.code`/`sr.id` (`:241-287`); one-hop children via `parent_id` (`:345-403`) | `/api/tools/fristenrechner/follow-ups` | +| `DeadlineSearchService` | `internal/services/fristenrechner_search_events.go:143-170,194,233,696` | sequencing_rules ⋈ procedural_events ⋈ proceeding_types + legal_sources; counts child rules via `parent_id` subquery | `/api/tools/fristenrechner/search` | +| `EventDeadlineService` | `internal/services/event_deadline_service.go:31-79,186-195,244` | `paliad.trigger_events` + `sequencing_rules WHERE trigger_event_id IS NOT NULL` | `/api/tools/event-deadlines` (legacy bigint surface) | +| `EventTriggerService` | `internal/services/event_trigger_service.go:24-230` | `event_types.trigger_event_id` bridge + sequencing_rules | `/api/tools/event-trigger` | +| `RuleEditorService` | `internal/services/rule_editor_service.go:104,136,232,371,381,459,625-843` | full CRUD on sequencing_rules + procedural_events; reads `trigger_event_id` as an optional filter on list | `/admin/api/procedural-events/*` (Slice B.5) | +| `RuleEditorOrphans` | `internal/services/rule_editor_orphans.go:218-224` | sub-select on sequencing_rules for orphaned deadlines | `/admin/api/orphans` | +| `DualWriteService` | `internal/services/dual_write.go` (+ `dual_write_test.go:50-300`) | parity assertion between legacy + unified projection | internal — write-side guard, no HTTP | +| `ProjectionService` (SmartTimeline) | `internal/services/projection_service.go:3+` | composes the timeline by reading via `DeadlineRuleService` + `FristenrechnerService`; does NOT touch `sequencing_rules` directly | `GET /api/projects/{id}/timeline`, milestone + counterclaim endpoints in `internal/handlers/projection.go:35-436+` | +| `ExportService` | `internal/services/export_service.go:1680` | bulk-exports `paliad.trigger_events` as the `ref__trigger_events` workbook sheet | `/api/admin/export/*` | +| `EventChoiceService` | `internal/services/event_choice_service.go:15-180` | reads + writes `paliad.project_event_choices` | per-project flag persistence (no rows live today) | +| `EventTypeService` | `internal/services/event_type_service.go:40-414` | user-defined `paliad.event_types` rows with optional `trigger_event_id` bridge | `/api/event-types` + Pipeline C compose | +| `ProjectService.validateProceedingTypeCategory` | `internal/services/project_service.go:1176-1267` | reads `paliad.proceeding_types.category` + `kind` + `is_active` | binding guard for `projects.proceeding_type_id` (sister to mig-153 trigger) | + +The handlers behind each route are listed in §1.2. + +### 1.2 HTTP routes + +Every route that ultimately surfaces sequencing/event data. Path +literals + handler file:line cited. + +**Knowledge-tool surface (public-ish, behind auth):** + +| Route | Handler | Reads | +|---|---|---| +| `POST /api/tools/fristenrechner` | `internal/handlers/fristenrechner.go:39-95+` | `FristenrechnerService.CalculateForProceeding` → engine in `pkg/litigationplanner` | +| `GET /api/tools/fristenrechner/search` | `internal/handlers/fristenrechner_search.go` (filter params: `event_kind`, `primary_party`, `jurisdiction`) | `DeadlineSearchService.SearchEvents` | +| `GET /api/tools/fristenrechner/follow-ups` | `internal/handlers/fristenrechner_followups.go:27-65` | `FristenrechnerService.LookupFollowUps` | +| `GET /api/tools/proceeding-types` | `internal/handlers/event_types.go` | proceeding_types filter (event_kind, jurisdiction) | +| `GET /api/tools/trigger-events` | `internal/handlers/event_types.go` | trigger_events catalog (active only) | +| `POST /api/tools/event-trigger` | `internal/handlers/event_trigger.go:39-106` | unified Pipeline-A + Pipeline-C compose | +| `POST /api/tools/event-deadlines` | `internal/handlers/deadline_rules_db.go:67+` | **legacy** bigint trigger_event_id → rule list | + +**SmartTimeline surface (project-bound):** + +| Route | Handler | Reads | +|---|---|---| +| `GET /api/projects/{id}/timeline` | `internal/handlers/projection.go:35-109` | `ProjectionService.Render` (no direct rule reads — composes via services) | +| `POST /api/projects/{id}/timeline/milestone` | `internal/handlers/projection.go:445+` | milestone insert; reads `proceeding_type.kind` via service | +| `POST /api/projects/{id}/timeline/counterclaim` | `internal/handlers/projection.go:387-436` | spawns CCR project; reads `parent_id` on response composition | + +**Admin editor surface (`/admin/procedural-events/*`):** + +| Route | Handler | Reads | +|---|---|---| +| `GET /admin/procedural-events` | `internal/handlers/admin_rules.go:399-402` | page shell | +| `GET /admin/procedural-events/{id}/edit` | `:403-470` | editor form (full rule + event JSON) | +| `GET /admin/api/procedural-events` | `:101-160` | paginated list w/ canonical `code` + `event_kind` (Slice B.5 wrapper) | +| `GET /admin/api/procedural-events/{id}` | `:161-179` | single rule fetch | +| `POST /admin/api/procedural-events` | `:180-204` | create draft | +| `PATCH /admin/api/procedural-events/{id}` | `:205-233` | edit draft | +| `POST /admin/api/procedural-events/{id}/publish` | `:257-279` | publish flow | +| `GET /admin/api/procedural-events/{id}/audit` | `:326-361` | audit log | +| `GET /admin/api/orphans` | `:471-484` | orphaned deadlines (Slice 10 backfill UI) | +| `POST /admin/api/orphans/{id}/resolve` | `:485-520` | link orphan to rule | +| `/admin/rules/*` → `/admin/procedural-events/*` | `:761-772` | **301 redirects** (legacy bookmarks; one-slice deprecation window) | +| `?trigger_event_id=…` query param | `:119-122` | exposes legacy trigger filter on the admin list | + +**Scenarios surface (mig 145):** + +| Route | Handler | +|---|---| +| `GET /api/scenarios?project=|abstract=true` | `internal/handlers/scenarios.go:51-90` | +| `GET /api/scenarios/{id}` | `:92-113` | +| `POST /api/scenarios` | `:115-136` | +| `PATCH /api/scenarios/{id}` | `:138-164` | +| `DELETE /api/scenarios/{id}` | `:166-200+` | +| `POST /api/paliadin/suggest/deadline` | `internal/handlers/paliadin_suggest.go:63+` (deadline drafts via Paliadin; does not read rules directly — calls into `DeadlineService`) | + +Registration: `internal/handlers/handlers.go:497-501, 880`. + +### 1.3 Frontend (TypeScript) consumers + +These call the routes above; **no direct DB access**. References per the +i18n key search and `frontend/src/client/*` greps: + +- `frontend/src/admin-rules-list.tsx:24-105+` — admin list page shell; + hits `/admin/api/procedural-events*`. +- `frontend/src/admin-rules-edit.tsx:29-187+` — admin editor form; reads + `procedural_events.edit.field.{code,event_kind,parent}` i18n keys. +- `frontend/src/verfahrensablauf.tsx` — proceeding-type ablauf page + (mode C); hits `/api/tools/fristenrechner` with proceeding shape. +- `frontend/src/client/fristenrechner-wizard.ts:80` — Mode A wizard; + `r4: string // procedural_events.code`. +- `frontend/src/client/fristenrechner-mode-a.ts` — Mode A search; hits + `/api/tools/fristenrechner/search?kind=events`. +- `frontend/src/client/fristenrechner-result.ts` — result panel; hits + `/api/tools/fristenrechner/follow-ups`. +- `frontend/src/client/projects-new.ts` — type-aware project wizard; + hits `/api/tools/fristenrechner?proceeding_type_code=…`. +- `frontend/src/client/deadlines-detail.ts` — deadline CRUD detail. +- i18n keys: `admin.procedural_events.list/edit/col.*` and translations + in `frontend/src/client/i18n.ts:3193-3204, 6338-6346+`. + +### 1.4 Offline snapshot + +- `cmd/gen-upc-snapshot/main.go:150-268` — reads `paliad.trigger_events`, + the legacy `paliad.deadline_rules` projection (now via the unified + view), and `paliad.proceeding_types`. Writes JSON to + `pkg/litigationplanner/embedded/upc/{trigger_events.json, + rules.json, proceeding_types.json, meta.json}`. +- `pkg/litigationplanner/catalog.go` + `engine.go` + `types.go:73-156` — + Rule struct carries `TriggerEventID`, `SpawnProceedingTypeID`, + `ConditionExpr`, `Priority`, `IsCourtSet`, `PrimaryParty`, `IsSpawn`, + `SpawnLabel`, `CombineOp`. youpc.org consumes this snapshot. + +### 1.5 Migrations touching the tables (chronological) + +`internal/db/migrations/`: + +`028_youpc_deadlines_import`, `030_event_types`, `033_trigger_events_de`, +`035_event_deadlines_title_de_backfill`, `038_concept_links_and_legal_source`, +`046_cross_cutting_triggers`, `047_deadline_search_view`, +`051_proceeding_display_order`, `063_frist_verpasst_upc`, +`078_unified_rule_columns`, `091_drop_legacy_rule_columns`, +`098_submission_codes_prefix_and_rename`, `125_cross_cutting_filter_legal_source`, +`132_wave1_tier1_rule_additions`, **`136_procedural_events_additive`** +(the schema-authoritative additive split), `139_deadline_rules_unified_view`, +**`140_drop_deadline_rules`** (legacy projection dropped), +`151_dedupe_null_procedural_events`, `152_dedupe_identical_sequencing_rule_clones`, +**`153_proceeding_types_kind`** (kind discriminator + projects FK trigger). + +Mig 145 is scenario-side: creates `paliad.scenarios` (table, **not** +a `scenarios` jsonb column on `projects` — the RFC text was imprecise) +and `paliad.projects.active_scenario_id` FK. + +--- + +## 2. Health-check per consumer + +### 2.1 Works — green + +- **`DualWriteService` parity.** Every CRUD on the editor surface + keeps sequencing_rules + procedural_events + legal_sources locked, + asserted by `dual_write_test.go:50-202`. +- **Admin editor (`/admin/procedural-events/*`).** Full create / edit / + publish / audit loop. Drafts state respected. +- **Mode A picker via search.** `DeadlineSearchService` filters by + `event_kind` / `primary_party` / `jurisdiction`; returns child-rule + counts (`fristenrechner_search_events.go:159`). +- **Mode B Verfahrensablauf calc.** `pkg/litigationplanner.CalculateRule` + + the `proceeding_type` fan-out works for every type that has any + rule (17/23). +- **`gen-upc-snapshot`.** UPC snapshot for youpc.org keeps shipping; + no DB writes; reads only. +- **Counterclaim spawn project creation.** + `internal/handlers/projection.go:387-436` + mig 153 trigger guard + reject any non-`proceeding` `proceeding_type_id`. +- **EventChoiceService** SQL is wired and tested — but see §2.3. + +### 2.2 Works with known caveats — yellow + +- **Spawn rules.** Behaviour is correct in the abstract (rule fires, + user can spawn a child case), but every spawn target points at the + **inactive** `upc.apl.merits` (id=11). Surfaces that resolve the + spawn target via `paliad.proceeding_types` will return an inactive + row. See R3. Cited at `sequencing_rules` 4 rows; service code in + `fristenrechner_followups.go:388` SELECTs `spt.code` via + `LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id` + — no `is_active` filter on the join. Frontend renders an "open + Berufungsverfahren" CTA that points at a UI flow expecting the + active id=160 (`upc.apl.unified`). +- **Legacy 73 globals.** 73 rules with `proceeding_type_id IS NULL` + and `trigger_event_id NOT NULL`. These all anchor on legacy + `null.<8hex>` event codes that don't match any `proceeding_types.code` + prefix. They are consumed via `/api/tools/event-deadlines` (the + bigint route) AND surface on the unified view. They have no place + in the Mode B "proceeding-type ablauf" view because they have no + proceeding. See R4. +- **Legacy `/api/tools/event-deadlines` route.** Live, used by + Pipeline-C `event_types` consumers (`EventTypeService`). The + `ExportService:1680` also still emits `ref__trigger_events` to the + workbook. Deprecation has been deferred — see R5. + +### 2.3 Broken / leaky — red + +- **B1 — Follow-up cross-party filter is over-broad.** + `fristenrechner_followups.go:358-367`: + + ```go + 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))) + } + ``` + + The filter keeps `both` + `NULL` rules but **drops cross-party + follow-ups**. From the corpus there are 39 active rules whose + `primary_party` differs from their parent's primary_party (excluding + `court`). Example: `upc.inf.cfi.def_to_ccr` is claimant-filed; its + child rule `RoP.029.d → reply_def_ccr` is defendant-filed. With + `party=claimant` selected on the result view, the defendant child + is hidden and the user reads "Keine Folge-Fristen" — a lie. This + is the exact bug the RFC §"What's actually broken" item 2 calls + out. + +- **B2 — Picker doesn't distinguish triggers from leaves.** + `LookupFollowUps` (`fristenrechner_followups.go:241-287`) resolves + by `pe.id` / `pe.code` / `sr.id` with no + "is-this-event-actually-a-trigger" gate. The data already supports + derivation — 67 of 222 active events act as a chain anchor. The + picker just isn't wired to the derivation. Compounding: 4 events + are *spawn-only* consequences (`upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn`) + — picking one returns the spawn rule itself with no follow-ups, + which surfaces as "Keine Folge-Fristen". + +- **B3 — Scenario state is forked across three stores by design but + zero stores by data.** + - `paliad.project_event_choices` (mig 129) — schema present, 0 rows. + `EventChoiceService` reads + writes it via + `internal/services/event_choice_service.go:74,123,180`. + - `paliad.scenarios` (mig 145) — 0 rows, 0/18 projects bound via + `active_scenario_id`. `ScenarioService.LoadScenarios` in + `internal/services/fristenrechner.go:583-627` reads it. + - DOM state on the result view — Verfahrensablauf checkbox state + only lives client-side. Confirmed by absence of a write path + from `verfahrensablauf.tsx` to either DB-side store. + + The RFC's "three independent stores" claim is *architecturally* + true today, but every store is empty. Risk is dormant — until + someone enables persistence on either path and the divergence + materialises. See R6. + +- **B4 — 6 active `proceeding_types` have zero rules.** + `upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`, + `upc.epo.review`, `upc.pl.cfi`. They appear in + `/api/tools/proceeding-types` (`is_active=true` + `kind='proceeding'`) + but produce empty timelines when chosen. The Mode A picker can + bind a project to them; the Mode B result view is blank. + +### 2.4 Dead-or-decaying code + +- **`paliad.trigger_events` table.** 110 rows; columns + `(id, code, name, name_de, description, is_active, created_at, concept_id)`. + Bigint PK. No `parent_id`, no `proceeding_type_id`. Consumed by: + `deadline_rule_service.go:226-285` (label fallback), `event_deadline_service.go` + (legacy route), `event_type_service.go` (Pipeline C bridge), + `export_service.go:1680` (workbook sheet), and 80 active + sequencing_rules' `trigger_event_id` (which is in turn primarily a + bridge for the 73 globals + 7 hybrid rules with a real proceeding). +- **Inactive proceeding_types still referenced by spawn rules.** + id 11 (`upc.apl.merits`), 19 (`upc.apl.cost`), 20 (`upc.apl.order`). + Mig 138 (`appeal_target_backfill_merits_order`) split them, mig + later unified them onto id 160. The 4 spawn rules' FK was not + updated. +- **3 non-`proceeding` kinds.** 23 rows total + (`phase` × 4 + `side_action` × 10 + `meta` × 9), all + `is_active=false` after mig 153. Live in the table for audit; + unused by any active surface. The Slice 10 orphan-resolution path + (`rule_editor_orphans.go`) could theoretically encounter them, but + active = false filters them out. + +--- + +## 3. Rules-corpus quality audit (live data) + +### 3.1 `parent_id` coverage + +- 107/226 active+published rules have `parent_id` set (**47%**, matches + RFC). +- 119/226 do not. Decomposition (active+published): + +| Subset | Rows | Meaning | +|---|--:|---| +| `parent_id NULL` AND `trigger_event_id IS NULL` AND `proceeding_type_id` set | 46 | Genuine proceeding-level roots (each PT has 1–6 such). | +| `parent_id NULL` AND `trigger_event_id` set AND `proceeding_type_id NULL` | 73 | The legacy globals — no place in the new chain model yet. | + +Of the 46 proceeding-level roots: + +| `proceeding_type.code` | roots | active rules | +|---|--:|--:| +| `de.inf.lg` | 5 | 9 | +| `de.null.bpatg` | 4 | 10 | +| `epa.grant.exa` | 4 | 7 | +| `upc.apl.unified` | 6 | 16 | +| `epa.opp.boa` | 3 | 8 | +| `upc.pi.cfi` | 3 | 7 | +| `epa.opp.opd` | 2 | 8 | +| `de.inf.bgh`, `de.inf.olg`, `de.null.bgh`, `dpma.appeal.bgh`, `dpma.appeal.bpatg`, `dpma.opp.dpma`, `upc.disc.cfi` | 1 each | various | +| `upc.dmgs.cfi`, `upc.inf.cfi`, `upc.rev.cfi` | 4 each | 8/25/17 | + +Most "root" rules are legitimate (the chain start event has no logical +predecessor — `Klageerhebung`, `Zustellung`, `Veröffentlichung`, +`Anmeldung`, etc.). A small number are leaves whose parent chain just +hasn't been seeded (e.g. `de.inf.lg.berufung` / `de.inf.lg.beruf_begr` +list "Berufungsfrist" and "Berufungsbegründung" as parent-NULL despite +both having a logical predecessor in `de.inf.lg.urteil`). + +### 3.2 `condition_expr` usage + +18 rules use the column. Three keys total: + +| Key | Uses | Sample shape | +|---|--:|---| +| `flag` | 14 | `{"flag":"with_ccr"}`, `{"flag":"with_amend"}`, `{"flag":"with_cci"}` | +| `op` | 4 | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` | +| `args` | 4 | always nested under an `op:and` | + +Distinct expressions (4 total, all UPC inf/rev): +`{"flag":"with_ccr"}` (×6), `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` (×4), `{"flag":"with_cci"}` (×4), `{"flag":"with_amend"}` (×4). + +No formal validation at write time — `RuleEditorService` accepts the +column as freeform jsonb. The 3 flags are de-facto convention. + +### 3.3 Spawn distribution + +4 rules, all in the UPC CFI cluster, all `priority='optional'` + +`primary_party='both'` + spawn target id=11 (`upc.apl.merits`, inactive): + +| Anchor event | Spawn label | Target | +|---|---|---| +| `upc.inf.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) | +| `upc.rev.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) | +| `upc.pi.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) | +| `upc.dmgs.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) | + +### 3.4 `primary_party` distribution + +Excluding the 73 globals (all NULL), the published+active rules split: + +| `proceeding_type` cluster | `claimant` | `defendant` | `both` | `court` | +|---|--:|--:|--:|--:| +| `upc.inf.cfi` (25) | 6 | 7 | 8 | 4 | +| `upc.rev.cfi` (17) | 6 | 7 | 1 | 3 | +| `upc.apl.unified` (16) | 0 | 0 | 12 | 4 | +| `de.null.bpatg` (10) | 2 | 2 | 3 | 3 | +| `de.inf.lg` (9) | 2 | 3 | 2 | 2 | +| `epa.opp.opd` (8) | 0 | 1 | 6 | 1 | +| `epa.opp.boa` (8) | 0 | 0 | 6 | 2 | +| `de.inf.bgh` (8) | 0 | 0 | 6 | 2 | +| `upc.dmgs.cfi` (8) | 2 | 2 | 1 | 3 | + +39 rules have a `primary_party` value that differs from their parent +rule's `primary_party` (excluding `court` ↔ anything, which is +trivial). All 39 are legitimate "ball-in-other-court" hand-offs +(claimant SoC → defendant SoD → claimant Reply → defendant Rejoinder +…). The /follow-ups filter (§2.3 B1) hides all of them when the user +picks a perspective. + +### 3.5 `is_court_set` coverage + +46 rules carry `is_court_set=true`. Distribution: every proceeding has +at least one (the decision / order / oral-hearing rows). Highest: +`de.inf.lg` (5), `epa.grant.exa` (4), `upc.apl.unified` (4), +`upc.inf.cfi` (3), `upc.rev.cfi` (3), `upc.pi.cfi` (3), `upc.dmgs.cfi` +(3). Calculator skips these in date math — they surface as +"wird vom Gericht bestimmt" markers. + +### 3.6 Legacy `trigger_event_id` overlap with `parent_id` + +| Combination | Rows | +|---|--:| +| `parent_id` set AND `trigger_event_id` set | **2** | +| `parent_id` set AND `trigger_event_id` NULL | 105 | +| `parent_id` NULL AND `trigger_event_id` set | 73 | +| `parent_id` NULL AND `trigger_event_id` NULL | 46 | + +**Overlap is 2 rules out of 226 (0.9%).** The two models are +effectively **disjoint** in the corpus: the 73 legacy globals own the +`trigger_event_id` lane; the 105 chain-linked rules own `parent_id`. +The schema permits both columns to be set simultaneously, and 2 rules +exercise that — but they are outliers, not a documented pattern. + +The legacy `paliad.trigger_events` table is still read for label +display by `deadline_rule_service.go:226-285` (the "abhängig von …" +chip rule fallback when `parent_id` isn't set) and for the legacy +`/api/tools/event-deadlines` route. + +--- + +## 4. Editorial gap map + +Per `proceeding_type` (active, kind=`proceeding`). Columns: + +- **A** = active+published rules +- **P** = rules with `parent_id` set +- **R** = rules without `parent_id` (roots + leaves with missing parent) +- **E** = active+published events whose code matches this PT's + prefix + +| PT code | A | P | R | E | Health | +|---|--:|--:|--:|--:|---| +| `upc.inf.cfi` | 25 | 21 | 4 | 25 | 84% chained — strongest | +| `upc.rev.cfi` | 17 | 13 | 4 | 17 | 76% | +| `upc.apl.unified` | 16 | 10 | 6 | 16 † | 63% — code-prefix issue, see below | +| `de.null.bpatg` | 10 | 6 | 4 | 10 | 60% | +| `de.inf.lg` | 9 | 4 | 5 | 9 | 44% — gappy | +| `epa.opp.opd` | 8 | 6 | 2 | 8 | 75% | +| `epa.opp.boa` | 8 | 5 | 3 | 8 | 63% | +| `de.inf.bgh` | 8 | 7 | 1 | 8 | 88% | +| `upc.dmgs.cfi` | 8 | 4 | 4 | 8 | 50% | +| `upc.pi.cfi` | 7 | 4 | 3 | 7 | 57% | +| `de.inf.olg` | 7 | 6 | 1 | 7 | 86% | +| `epa.grant.exa` | 7 | 3 | 4 | 7 | 43% | +| `de.null.bgh` | 6 | 5 | 1 | 6 | 83% | +| `dpma.appeal.bpatg` | 5 | 4 | 1 | 5 | 80% | +| `dpma.appeal.bgh` | 4 | 3 | 1 | 4 | 75% | +| `dpma.opp.dpma` | 4 | 3 | 1 | 4 | 75% | +| `upc.disc.cfi` | 4 | 3 | 1 | 4 | 75% | +| `upc.bsv.cfi` | 0 | 0 | 0 | 0 | **unruled** | +| `upc.ccr.cfi` | 0 | 0 | 0 | 0 | **unruled** | +| `upc.costs.cfi` | 0 | 0 | 0 | 0 | **unruled** | +| `upc.dni.cfi` | 0 | 0 | 0 | 0 | **unruled** | +| `upc.epo.review` | 0 | 0 | 0 | 0 | **unruled** | +| `upc.pl.cfi` | 0 | 0 | 0 | 0 | **unruled** | + +† `upc.apl.unified` (id=160) is the active type, but its 16 events +retain the *legacy* code prefixes `upc.apl.{merits,cost,order}.*` +from the pre-unification taxonomy. The rules' `proceeding_type_id` +was rebound to 160; the event codes were not renamed. Functional but +inconsistent — see R3. + +**Events with no rule:** 0. Every active+published event has at least +one rule (corpus is 1:1 since mig 136). Editorial gap is therefore +parent-chain-shaped, not rule-coverage-shaped. + +**Unmatched-prefix events:** 69 events with `code LIKE 'null.%'`. They +have rules (the 73 legacy globals — note the disparity: 73 rules but +69 events, because dedupe in mig 151 collapsed some duplicates while +the rules still point at the canonical event). They do not belong to +any proceeding_type and never will under the current taxonomy. + +--- + +## 5. Risk register + +Eleven items. Each: what, where, severity. Severity scale: +**critical** (user-visible incorrect output / data loss possible) → +**high** (user-visible UX lie, no data corruption) → **medium** +(developer-trap; breaks at next refactor) → **low** (cosmetic / dead +code, deferred maintenance). + +### R1 — Cross-party follow-up filter drops legitimate hand-offs — **high** + +- Where: `internal/services/fristenrechner_followups.go:358-367`. +- Effect: with `party=claimant|defendant`, 39 active rules are hidden + because their `primary_party` is the *other* side. Result-view + reports "Keine Folge-Fristen" on chains that continue cross-party + (e.g. `def_to_ccr` claimant-filed → `reply_def_ccr` defendant-filed + in `upc.inf.cfi`). +- Impact: UX lies to users about chain completion; can lead to missed + deadlines on the opposing side's view. + +### R2 — Picker accepts spawn-only and leaf events — **high** + +- Where: `internal/services/fristenrechner_followups.go:241-287` (anchor + resolution does not check chain-anchor status); `internal/services/fristenrechner_search_events.go` + (search returns every event). +- Effect: Picking `upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn` (spawn-only) + shows the spawn rule itself but no follow-ups → "Keine Folge-Fristen". + Picking a leaf event (e.g. `upc.inf.cfi.def_to_ccr`) only reaches + whatever hop-1 children exist on the leaf's own party, see R1. +- 67/222 active events are chain-anchors. Today's picker shows all + 222 with equal weight. + +### R3 — 4 spawn rules point at an inactive `proceeding_type` — **high** + +- Where: 4 rows in `paliad.sequencing_rules` with `is_spawn=true` + and `spawn_proceeding_type_id=11` (`upc.apl.merits`, `is_active=false`). + The active appeal type is id=160 (`upc.apl.unified`). +- Effect: any consumer that joins on `spt.is_active=true` (none today, + but the moment any does) returns NULL for the spawn target. Today + the join is permissive (`fristenrechner_followups.go:394`) — it + returns `upc.apl.merits` to the frontend, which may surface as a + CTA pointing at a stale type slug. +- Plus consequence: `upc.apl.unified` events kept legacy code prefixes + `upc.apl.{merits,cost,order}.*` even though the type rebinds to 160. + Code/PT mismatch is harmless today; trap for any future code-prefix + routing. + +### R4 — 73 "global" legacy rules orphan from the chain model — **medium** + +- Where: `paliad.sequencing_rules WHERE proceeding_type_id IS NULL AND trigger_event_id IS NOT NULL` (73 rows). Anchored on `null.<8hex>` + procedural_events (69 distinct events, 73 rules — small overlap from + pre-dedupe history). +- Effect: invisible to Mode B (proceeding-type ablauf) because they + don't bind to any PT; visible to the legacy bigint route + `/api/tools/event-deadlines` and to /admin/procedural-events. +- Migration debt: any "deprecate `trigger_event_id`" plan must decide + whether to (a) reparent these onto a PT + parent chain, (b) keep them + as floating cross-cutting rules in a separate lane, or (c) drop them. + +### R5 — Legacy `paliad.trigger_events` table is read by 5 surfaces — **medium** + +- Where: + - `internal/services/deadline_rule_service.go:226-285` — bulk-load for + "abhängig von …" chip label fallback. + - `internal/services/event_deadline_service.go:79,244` — legacy + `/api/tools/event-deadlines` route. + - `internal/services/event_type_service.go:40-414` — Pipeline-C event + types bridge (`event_types.trigger_event_id`). + - `internal/services/export_service.go:1680` — `ref__trigger_events` + workbook sheet. + - `cmd/gen-upc-snapshot/main.go:185-202` — UPC offline snapshot for + youpc.org. +- Effect: 110-row catalog with bigint PK lives alongside the 222 active + procedural_events (UUID PK). Two ID spaces, two label sources, + partial overlap. + +### R6 — Three scenario stores: 0 rows each, but 3 live read/write paths — **medium** + +- Stores: `paliad.project_event_choices` (0 rows), `paliad.scenarios` + (0 rows), DOM state on Verfahrensablauf checkboxes. +- Paths: + - `EventChoiceService` (`internal/services/event_choice_service.go:15-180`) + reads + writes the table. + - `ScenarioService.LoadScenarios` + handlers + (`internal/services/fristenrechner.go:583-627`, `internal/handlers/scenarios.go:14-200+`) + read + write the table. + - Verfahrensablauf result view writes nothing back — DOM only. +- Effect today: nothing — empty tables. Effect tomorrow: the moment any + surface starts persisting, the three paths can diverge. The RFC + (§"What's actually broken" item 3) calls out the symptom: toggling + "Mit Widerklage" on Verfahrensablauf doesn't drive conditional + checkboxes in result-view submission cards. + +### R7 — 6 active `proceeding_types` are entirely unruled — **medium** + +- Where: `upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`, + `upc.epo.review`, `upc.pl.cfi`. All `is_active=true`, `kind='proceeding'`, + 0 active+published rules, 0 events with their code prefix. +- Effect: pickable on `/api/tools/proceeding-types`, bindable on + `paliad.projects.proceeding_type_id` (mig 153 only rejects non- + proceeding kind, not zero-rule). Binding succeeds → SmartTimeline + + Mode B render an empty result. UX lies. + +### R8 — `condition_expr` is freeform jsonb — **medium** + +- Where: column declaration in mig 136; consumer in + `deadline_rule_service.go` (selected + passed to engine in + `pkg/litigationplanner/engine.go`); writer in + `internal/services/rule_editor_service.go:625-843` (no validation). +- Effect: 4 distinct shapes used today, 3 keys (`flag`, `op`, `args`). + No write-time validation. New keys can be silently added; the + engine consumes by switching on string literals. Refactor trap. + +### R9 — Inactive `proceeding_types` rows linger (23) — **low** + +- Where: mig 153 flipped 4 phase + 10 side_action + 9 meta rows to + `is_active=false`. They still exist for audit. +- Effect: snapshots and snapshots-of-snapshots + (`proceeding_types_pre_153`, `procedural_events_pre_151`, + `sequencing_rules_pre_151/_pre_152`) accumulate without a decay + policy. Storage cost is trivial; query-shape cost is real if any + query forgets `WHERE kind='proceeding' AND is_active=true`. + +### R10 — `event_kind` is nullable + not enumerated in DB — **low** + +- Where: `paliad.procedural_events.event_kind text NULL`. Code at + `frontend/src/admin-rules-edit.tsx:187` lists `filing / hearing / + decision / order` in the UI but the DB accepts anything. +- Effect: drift between UI vocab and persisted values is possible. + Currently 5 buckets: `filing`, `hearing`, `decision`, `order`, NULL + (per RFC). + +### R11 — `applies_to_target` + `choices_offered` lack a schema — **low** + +- Where: `paliad.sequencing_rules.applies_to_target text[]`, + `choices_offered jsonb`. +- Effect: 16 rules use `applies_to_target`, 28 use `choices_offered`. + Three observed `choices_offered` shapes: `{appellant:[…]}` (20), + `{skip:[…]}` (6), `{include_ccr:[…]}` (2). Wire-level convention, + no documentation. New shapes silently land if a future editor + decides on one. + +--- + +## 6. Recommendation — order of operations for the inventor + +Phase 2 design starts with the highest-stakes, hardest-to-rewind +decisions and finishes with editorial/cleanup. Each step is a +question for m, not a design choice for the inventor. + +### Tier 1 — model decisions (grill first) + +1. **Trigger semantics.** Keep `parent_id` as the canonical link? + What is the role of `trigger_event_id` after this RFC ships? If + deprecated, what happens to the 73 legacy globals (R4) — reparent + onto PTs, keep as a separate "cross-cutting" lane, or drop? +2. **Trigger discoverability.** Derive from data (events that + parent ≥1 rule = 67 today), maintain a materialised view, or carry + an explicit `is_trigger` flag on `procedural_events`? Affects R2. +3. **Scenario state — single home.** Of the three stores in R6, which + wins? Migration shape for the others? The RFC mis-spoke about + `projects.scenarios jsonb` — the table is `paliad.scenarios` with + a `spec` jsonb column (mig 145). Confirm which storage the inventor + reasons from. +4. **Cross-party display semantics.** Backend stops filtering, + frontend groups by side? Or backend tags + frontend renders an + "andere Partei" group? Affects R1. + +### Tier 2 — surface decisions + +5. **Spawn → consequence-only events.** Stop surfacing spawn-only + events in the picker (R2), or keep them and tag visually? +6. **Re-target the 4 spawn rules** (R3) — point at id=160 vs reseed + legacy ids; align event code prefixes vs. accept the mismatch. +7. **Sequence-from-proceeding-type view** (Entry A). Where does it + live? How do its toggles persist to the chosen scenario store? +8. **Legacy `/api/tools/event-deadlines` deprecation** (R5). Drop, + redirect, or keep behind a flag during transition? + +### Tier 3 — editorial + cleanup + +9. **Editorial backfill plan.** Which of the 119 parent-NULL rules + are real roots vs. unseeded leaves (a per-PT walkthrough by m). +10. **Empty proceeding_types** (R7). Stub with placeholder rules, or + hide from the picker until rules land? +11. **`condition_expr` formalisation** (R8). Pick a grammar, document + it, add write-time validation. Same question for `choices_offered` + + `applies_to_target` (R11). +12. **Legacy `trigger_events` table fate.** Drop, archive, or + repurpose? Depends on Q1 + Q2 above. + +The inventor should grill m on Tier 1 before sketching anything. +Tier 2 follows from Tier 1's decisions. Tier 3 is mechanical once +Tier 1+2 land. + +--- + +## Appendix — query receipts + +All counts in this assessment came from the live `paliad` schema on +the youpc Supabase instance during the audit window (2026-05-27). +Representative queries: + +```sql +-- §0 + §3.1 + §3.6 +SELECT + CASE + WHEN parent_id IS NOT NULL AND trigger_event_id IS NOT NULL THEN 'both' + WHEN parent_id IS NOT NULL AND trigger_event_id IS NULL THEN 'parent only' + WHEN parent_id IS NULL AND trigger_event_id IS NOT NULL THEN 'legacy only' + ELSE 'neither (root)' + END AS classification, + proceeding_type_id IS NULL AS pt_null, count(*) AS rules +FROM paliad.sequencing_rules +WHERE is_active AND lifecycle_state = 'published' +GROUP BY classification, pt_null +ORDER BY classification, pt_null; +-- → both/false=2, legacy only/true=73, neither/false=46, parent only/false=105 + +-- §3.4 +SELECT pt.code, sr.primary_party, count(*) +FROM paliad.sequencing_rules sr +LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id +WHERE sr.is_active AND sr.lifecycle_state='published' +GROUP BY pt.code, sr.primary_party ORDER BY pt.code, count(*) DESC; + +-- §4 (gap map) +SELECT pt.code, count(sr.id) AS active_rules, + count(*) FILTER (WHERE sr.parent_id IS NULL) AS roots +FROM paliad.proceeding_types pt +LEFT JOIN paliad.sequencing_rules sr ON sr.proceeding_type_id = pt.id + AND sr.is_active AND sr.lifecycle_state='published' +WHERE pt.is_active AND pt.kind='proceeding' +GROUP BY pt.code ORDER BY pt.code; + +-- §3.2 (condition_expr keys) +WITH expanded AS ( + SELECT jsonb_object_keys(condition_expr) AS k + FROM paliad.sequencing_rules + WHERE condition_expr IS NOT NULL AND condition_expr::text <> '{}' +) SELECT k, count(*) FROM expanded GROUP BY k ORDER BY count(*) DESC; +-- → flag=14, args=4, op=4 +``` + +Full set of queries used during the audit is available in the agent +transcript; reproducible against any read-only Supabase role. + +— end of assessment.