# 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=<id>|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.
