Files
paliad/docs/design-fristen-phase2-2026-05-15.md
mAi cc13a5b857
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
chore(admin): remove /admin/rules/export page + export-migrations API (t-paliad-297)
Workflow shifted to hand-written numbered migrations; the audit-row SQL
export tool no longer has any consumers. Pure deletion — /admin/rules
and /admin/rules/{id}/edit stay; only the export-to-SQL flow goes.

Deleted:
- frontend/src/admin-rules-export.tsx
- frontend/src/client/admin-rules-export.ts

Removed:
- routes GET /admin/rules/export and GET /admin/api/rules/export-migrations
- handleAdminExportRuleMigrations + handleAdminRulesExportPage
- RuleEditorService.ExportMigrationsSince + ExportResult + sqlEscape helper
- build.ts entries (import, client bundle, dist HTML write)
- Sidebar "Regel-Migrations" nav item + "Migrations exportieren" button on /admin/rules
- all admin.rules.export.* + nav.admin.rules_export + admin.rules.list.export i18n keys (DE+EN)
- .admin-rules-export-* CSS rules (dead after page deletion)

Doc references in design-fristen-phase2-2026-05-15.md and
design-paliad-data-export-2026-05-19.md updated to mark the endpoint as
removed (acceptance #2 requires grep to return zero hits).
2026-05-26 11:50:14 +02:00

1079 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design — Fristen logic Phase 2 (post-audit extensions, prioritized slicing)
**Author:** pauli (inventor)
**Date:** 2026-05-15
**Task:** t-paliad-181 (child of t-paliad-157, audit merged at `79f6be3`)
**Branch:** `mai/pauli/fristen-phase2-design` (fresh from `origin/main` @ `79f6be3`)
**Status:** DESIGN READY FOR REVIEW — head gates the design → Phase 3 transition (no m-gate; autonomy mandate from 2026-05-15 00:01).
**Predecessor:** `docs/audit-fristen-logic-2026-05-13.md` §9 + the lock-in summary in `t-paliad-181`'s task description.
---
## 0. Premises verified live (drift check since the 2026-05-13 audit)
The audit's live findings were captured on 2026-05-13. Re-checked on 2026-05-15 against `paliad` schema on the youpc Supabase instance. Latest migration: **077** (audit was at 074).
### 0.1 Row-count drift since the audit
| Surface | 2026-05-13 audit | 2026-05-15 now | Δ |
|---|---|---|---|
| `paliad.deadline_rules` | 172 | **172** | 0 |
| `paliad.proceeding_types` (all) | 27 | **26** | -1 |
| ↪ `category='fristenrechner'` active | 20 | **19** | -1 |
| ↪ `category='litigation'` active | 7 | **7** | 0 |
| `paliad.deadline_concepts` | 56 | **57** | +1 |
| `paliad.deadline_concept_event_types` | 32 | **32** | 0 |
| `paliad.event_categories` | 125 | **125** | 0 |
| `paliad.event_types` | ~40 | **45** | +5 |
| `paliad.trigger_events` | 110 | **110** | 0 |
| `paliad.event_deadlines` | 77 | **77** | 0 |
| `paliad.deadlines` (live) | 26 | **26** | 0 |
| `paliad.deadlines WHERE rule_id IS NOT NULL` | 1 | **1** | 0 |
| `paliad.projects` | 11 | **11** | 0 |
| `paliad.projects WHERE proceeding_type_id IS NOT NULL` | 0 | **0** | 0 |
| `paliad.courts` | 41 | **41** | 0 |
| `paliad.holidays` | 55 | **55** | 0 |
**Conclusion:** every key finding from the audit holds. The single deactivated fristenrechner code is informational, not structural; the design treats `is_active` as the canonical liveness signal. **Demand-side is still empty** (11/11 NULL `proceeding_type_id`, 1/26 `rule_id` populated) — the migration cutover plan in §3 retains the same graceful-degrade contract.
### 0.2 New migrations 075077 are SmartTimeline-related, not Fristen-logic
- **075** `project_events_timeline_kind` — t-paliad-171 SmartTimeline Slice 1.
- **076** `smart_timeline_slice_2` — t-paliad-173 Slice 2 (projection meta).
- **077** `projects_counterclaim_of` — t-paliad-174 Slice 3 (CCR sub-project FK on `paliad.projects`).
None touch `paliad.deadline_rules`, `paliad.trigger_events`, `paliad.event_deadlines`, or `paliad.deadline_concepts`. **Fristen-logic data model is unchanged.** But §7 (instance level) intersects with `paliad.projects` so we account for the `counterclaim_of` neighbour when adding `instance_level`.
### 0.3 Audit doc is on main
Audit merged at `79f6be3` (`docs/audit-fristen-logic-2026-05-13.md`, 799 lines). All §6/§7 cross-references in this design point at the merged commit. Anchor as-of-2026-05-15 is stable.
### 0.4 Anchor files (re-confirmed)
Same as audit §0.5:
- `internal/services/fristenrechner.go` (~735 LoC) — Pipeline A + B.
- `internal/services/deadline_calculator.go` — pure date math.
- `internal/services/deadline_rule_service.go` — read API + `ConceptDefaultEventTypeID` hydration.
- `internal/services/event_deadline_service.go` (~300 LoC) — Pipeline C.
- `internal/services/projection_service.go` — SmartTimeline (Pipeline A consumer).
- `internal/services/deadline_service.go``paliad.deadlines` persistence.
- `internal/services/event_category_service.go` — cascade → concept.
- `internal/services/holidays.go` + `courts.go` — non-working-day adjustment.
If anything in this design conflicts with the live state, the live state wins.
---
## 1. Vision
m's locked decisions on the audit's §9 (verbatim from t-paliad-181):
- **Q1 — Pipeline reconciliation:** reconcile and combine Pipeline A + C into ONE corpus. "Needs to be all the same basis."
- **Q2 — Litigation vs fristenrechner:** **(b) soft-merge** to fristenrechner-only on projects. "I dont even get 'litigation corpus'? If pauli leans b, go for b."
- **Q3 — Mandatory/optional:** 4-way enum (`mandatory` / `recommended` / `optional` / `informational`).
- **Q5 — Rule-management UX:** **(C) full editor** with audit log. Admin-only.
- **Q6 — Condition grammar:** move to `condition_expr jsonb` (AND/OR/NOT).
- **Q11 — Pipeline A capability gaps:** add `before` timing, `working_days` unit, `combine_op` (min/max). Absorbed into Q1.
- **Q14 — Phase-2 cadence:** (b) pauli drafts the Phase 2 design.
Head's autonomous defaults on the unanswered 8:
- **Q4 — Event-trigger endpoint:** BUILD as part of the unification (`POST /api/tools/event-trigger`).
- **Q7 — Cross-proceeding spawn:** WIRE.
- **Q8 — Orphan concept order:** `wiedereinsetzung > schriftsatznachreichung > versaeumnisurteil-einspruch > weiterbehandlung > others`.
- **Q9 — Instance-level on projects:** ADD column.
- **Q10 — Backfill `rule_id` on legacy deadlines:** YES (fuzzy-match one-off).
- **Q12 — Court-set as real column:** PROMOTE.
- **Q13 — `condition_rule_id` dead column:** DROP (replaced by Q6's jsonb grammar).
- **Q15 — Phase 3 framing:** cover all locked Qs + defaults across slices.
### 1.1 Scope of this design
Produce a **detailed Phase 3 implementation roadmap** that operationalises all 15 picks above. The output is THIS document; Phase 3 = the slices in §10 executed by Sonnet coders (or pauli in pair-prog mode) under head's gates.
### 1.2 Explicitly out of scope for Phase 2
- **No implementation, no migrations, no SQL.** Design only.
- **No UI design for the rule editor's individual form fields** — §4 lays out the architecture; per-field forms get drafted in Phase 3 slice §10.7 with frontend-design input.
- **No coverage-gap rule authoring** — orphan concepts (§7.9 in the audit) are queued behind the schema landing; Phase 3 slice §10.10 schedules legal-review-gated rule additions.
### 1.3 Why this matters
Three intertwined problems collapse into one fix:
1. **The 3-parallel-systems mess** (Pipeline A vs B vs C) forces the cascade UI, SmartTimeline, and the "Was kommt nach…" tab to consult different corpuses for the same conceptual deadline. The unified model eliminates the drift surface.
2. **The "all in the Rules so we should be able to manage" promise** is unfulfilled today. Q5(C) lands the editor, and Q12/Q3/Q6 give it cleaner columns to edit.
3. **Coverage gaps** (9 orphan concepts, missing `before`/`working_days`/`combine_op`, half-wired spawn) cap the system's ability to express real-world law. The unified model has room for them.
---
## 2. Unified rule model — post-merge schema
This is the target data shape after Phase 3 lands. The migration order in §3 walks the corpus to this shape without destructive change.
### 2.1 The single rule table: `paliad.deadline_rules` (evolved, not replaced)
Keep the name. Evolve the schema. Existing FKs survive.
```text
paliad.deadline_rules
├── identity
│ ├── id uuid PK
│ ├── proceeding_type_id int FK → proceeding_types(id) NULLABLE
│ ├── trigger_event_id int FK → trigger_events(id) NULLABLE ← NEW (Q1)
│ ├── parent_id uuid self-FK NULLABLE
│ ├── concept_id uuid FK → deadline_concepts NULLABLE
│ ├── spawn_proceeding_type_id int FK → proceeding_types(id) NULLABLE ← NEW (Q7)
│ └── code text (rule-local code, e.g. `inf.sod`)
├── labels + citation
│ ├── name text NOT NULL
│ ├── name_en text NOT NULL DEFAULT ''
│ ├── description text
│ ├── rule_code text (legal citation rule code)
│ ├── legal_source text (structured citation, e.g. UPC.RoP.23.1)
│ ├── deadline_notes text (DE)
│ ├── deadline_notes_en text (EN)
│ └── spawn_label text
├── math: anchor + offset + adjustment
│ ├── duration_value int NOT NULL DEFAULT 0
│ ├── duration_unit text NOT NULL DEFAULT 'months' ← values: days|weeks|months|working_days ← NEW (Q11)
│ ├── timing text NOT NULL DEFAULT 'after' ← values: after|before ← NEW (Q11)
│ ├── anchor_alt text (e.g. priority_date)
│ ├── alt_duration_value int
│ ├── alt_duration_unit text
│ ├── alt_rule_code text
│ └── combine_op text ← values: NULL|max|min ← NEW (Q11)
├── conditional gating
│ ├── condition_expr jsonb ← NEW (Q6) replaces condition_flag
│ └── (condition_flag text[] DROPPED — superseded after backfill — see §3)
│ └── (condition_rule_id uuid DROPPED — Q13)
├── party + bilateral
│ ├── primary_party text (claimant|defendant|both|court)
│ └── is_bilateral boolean NOT NULL DEFAULT false
├── lifecycle
│ ├── priority text NOT NULL DEFAULT 'mandatory' ← values: mandatory|recommended|optional|informational ← NEW (Q3)
│ ├── (is_mandatory bool DROPPED — backfilled into priority — see §3)
│ ├── (is_optional bool DROPPED — backfilled — see §3)
│ ├── is_court_set boolean NOT NULL DEFAULT false ← NEW (Q12) replaces heuristic
│ ├── is_spawn boolean NOT NULL DEFAULT false
│ ├── is_active boolean NOT NULL DEFAULT true
│ ├── sequence_order int NOT NULL DEFAULT 0
│ ├── event_type text (category: filing|hearing|decision|order)
│ ├── created_at timestamptz NOT NULL DEFAULT now()
│ └── updated_at timestamptz NOT NULL DEFAULT now()
└── rule-editor lifecycle (NEW, Q5)
├── lifecycle_state text NOT NULL DEFAULT 'published' ← values: draft|published|archived
├── draft_of uuid self-FK NULLABLE (when state=draft, points at the published row this draft replaces)
└── published_at timestamptz (NULL while draft)
```
**Net delta** vs today: +6 columns (`trigger_event_id`, `spawn_proceeding_type_id`, `combine_op`, `condition_expr`, `priority`, `is_court_set`, `lifecycle_state`, `draft_of`, `published_at`), 3 columns (`condition_flag`, `condition_rule_id`, `is_mandatory`, `is_optional`). Net **+5 columns**, table grows from 32 to 37 columns.
### 2.2 Why keep `paliad.deadline_rules` instead of a new table
- **171/172 rules carry `concept_id`** — no need to re-link.
- **108/172 carry `parent_id`** — self-FK survives column adds.
- **`paliad.deadlines.rule_id` FK** points at `deadline_rules.id` — 1 live deadline depends on this.
- **`paliad.event_category_concepts → deadline_concepts → deadline_rules`** chain stays intact.
Renaming the table = ~12 FK + ~6 service files + tests rewrite for zero gain.
### 2.3 The 4-way `priority` enum (Q3)
| Value | UI badge | Save-modal default | Semantic |
|---|---|---|---|
| `mandatory` | red dot | ☑ pre-checked | "Muss adressiert werden" — legal must. |
| `recommended` | amber dot | ☑ pre-checked | "Sollte adressiert werden" — strong best-practice. |
| `optional` | grey dot | ☐ pre-unchecked | "Kann adressiert werden" — discretionary. |
| `informational` | no dot | (never saves) | "Zur Kenntnis" — surfaces in timeline but is NEVER saved as a deadline. Used for procedural milestones like Hauptverhandlung the lawyer doesn't "do" but needs to know about. |
**Backfill rules** (audit §7.3):
- `is_mandatory=true, is_optional=false``mandatory` (~155 rules).
- `is_mandatory=true, is_optional=true``optional` (~6 rules).
- `is_mandatory=false` (no live rows exist, but defensive) → `recommended`.
- For court-set rules (where `is_court_set=true` after Q12 promotion) that the lawyer doesn't "do" — flag them `informational` in a Phase 3 slice (legal review per rule).
### 2.4 The `condition_expr` jsonb grammar (Q6)
The audit's §7.7 sketch, formalised:
```json
{
"op": "and",
"args": [
{ "flag": "with_ccr" },
{ "op": "not", "args": [ { "flag": "expedited" } ] }
]
}
```
Node types:
- **Flag reference:** `{ "flag": "<name>" }` — true iff `<name>` is in the caller's `Flags` set.
- **Logical AND:** `{ "op": "and", "args": [...] }` — true iff every arg is true. Empty args = `true`.
- **Logical OR:** `{ "op": "or", "args": [...] }` — true iff any arg is true. Empty args = `false`.
- **Logical NOT:** `{ "op": "not", "args": [<single node>] }` — true iff the arg is false. Exactly one arg.
- **(Reserved for future) rule reference:** `{ "rule_code": "<code>" }` — true iff the calculator already processed that rule in this run. Reservation only; Phase 3 doesn't implement (would resurrect the dead `condition_rule_id` semantic).
**`null` or `{}`** = unconditional (every rule renders). Default for newly-created rules.
**Backfill from `condition_flag text[]`:**
- `NULL` or `'{}'``null`.
- `['with_ccr']``{"flag":"with_ccr"}` (simplified single-flag — no need to wrap in AND).
- `['with_ccr', 'with_amend']``{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`.
After Phase 3 slice §10.2, the AND-of-array semantic continues to work; new rules can use OR/NOT.
### 2.5 The unified trigger model — proceeding-driven AND event-driven
Today: Pipeline A (`proceeding_type_id`) and Pipeline C (`trigger_event_id`) live in disjoint tables. After unification:
- A rule with **`proceeding_type_id` set + `trigger_event_id` NULL** = proceeding-rooted (today's Pipeline A behaviour). 172 existing rules land here.
- A rule with **`proceeding_type_id` NULL + `trigger_event_id` set** = event-rooted (Pipeline C). 77 migrated rules from `paliad.event_deadlines` land here.
- A rule with **BOTH set** = "this is a proceeding rule, AND it can be triggered as a standalone event". Future-friendly composability — e.g. `inf.sod` could become event-callable via its trigger_event_id.
- A rule with **NEITHER set** = invalid; CHECK constraint rejects.
The calculator becomes one function — `Calculate(filter, triggerDate, opts)` — where filter is `{proceeding_code?, event_type?, rule_id?}`. The internal dispatch reads `condition_expr`, `parent_id` chains, `anchor_alt` etc. uniformly.
### 2.6 The `spawn_proceeding_type_id` column (Q7)
Today: 8 rules have `is_spawn=true`. The calculator notes (`projection_service.go:896-901`) that cross-proceeding spawn fails to resolve the target rules. After this column lands:
- `is_spawn=true, spawn_proceeding_type_id=X` — when this rule fires (e.g. APP.notice off INF.decision), the calculator follows the FK to load proceeding X's rule set and emits its root rule chain.
- The dependency annotation now resolves across proceedings (the global rule index in §6 makes this O(1)).
- Cycle guard: pre-flight check during `Calculate()` to detect `A → B → A` loops; abort with `ErrCyclicSpawn`.
### 2.7 `instance_level` on `paliad.projects` (Q9)
New column:
```text
ALTER TABLE paliad.projects
ADD COLUMN instance_level text;
-- values: NULL | 'first' | 'appeal' | 'cassation' (BGH-Revision / EPA-Beschwerde)
```
Combined with `proceeding_type.code` + `jurisdiction`, lets the SmartTimeline auto-derive:
- DE_INF + first → use `DE_INF`.
- DE_INF + appeal → use `DE_INF_OLG`.
- DE_INF + cassation → use `DE_INF_BGH`.
UI: a small picker on the project-detail page ("Stand: erste Instanz / Berufung / Revision") that auto-advances when a Berufungsschrift fires on the actuals side (Phase 3 slice §10.8).
### 2.8 `paliad.deadline_rule_audit` — the audit log table (Q5)
```text
paliad.deadline_rule_audit
├── id uuid PK DEFAULT gen_random_uuid()
├── rule_id uuid FK → deadline_rules(id) -- NOT NULL
├── changed_by uuid FK → auth.users(id) -- NOT NULL
├── changed_at timestamptz NOT NULL DEFAULT now()
├── action text NOT NULL -- create|update|publish|archive|restore
├── before_json jsonb -- row state pre-change (NULL on create)
├── after_json jsonb -- row state post-change (NULL on archive)
├── reason text NOT NULL -- required justification (>= 10 chars)
└── migration_exported boolean NOT NULL DEFAULT false -- true once this delta has been folded into a checked-in migration
```
RLS: visible to `is_global_admin=true` users only.
A DB trigger on `deadline_rules` INSERT/UPDATE/DELETE captures the change. The trigger reads `auth.uid()` for `changed_by`. The trigger also requires a session-level setting `paliad.audit_reason` to be set (cleared after each statement), enforcing the "reason" requirement.
Defense-in-depth: the rule-editor service also writes audit rows from Go (so the reason text is captured pre-trigger), and the trigger is the backstop for direct SQL.
### 2.9 What survives unchanged
- `paliad.deadline_concepts` — no schema change.
- `paliad.event_category_concepts` — no schema change.
- `paliad.deadline_concept_event_types` — no schema change. Now used by both Pipeline-A and Pipeline-C unified rules.
- `paliad.event_categories` (cascade taxonomy) — no schema change.
- `paliad.proceeding_types` — no schema change, but `category` becomes informational-only after Q2 soft-merge (every project picks `category='fristenrechner'` codes; litigation codes stay but become unused for project-binding).
- `paliad.courts` + `paliad.holidays` — no change.
---
## 3. Migration path
The cutover sequence preserves all live data and keeps the system functional at every step. Each step is a discrete migration file and a discrete Phase 3 slice.
### 3.1 Step-by-step (with file-numbers TBD, currently next is 078)
**Step A — Additive schema**
- Migration 078: add columns to `paliad.deadline_rules`: `trigger_event_id`, `spawn_proceeding_type_id`, `combine_op`, `condition_expr jsonb`, `priority text DEFAULT 'mandatory'`, `is_court_set bool DEFAULT false`, `lifecycle_state text DEFAULT 'published'`, `draft_of uuid`, `published_at timestamptz`.
- Migration 079: create `paliad.deadline_rule_audit` + RLS + trigger.
- Migration 080: add `paliad.projects.instance_level text`.
- Migration 081: add `paliad.proceeding_types` `display_order` constraints (already in place from mig 051 — verify, no-op if so).
**No data change yet.** Calculator still reads old columns. Editor not yet built. Verifies build + tests pass against the dual schema.
**Step B — Backfill**
- Migration 082: backfill `is_court_set` from the runtime heuristic (`primary_party='court' OR event_type IN ('hearing','decision','order')`). Defensive — log delta count.
- Migration 083: backfill `priority`:
```sql
UPDATE paliad.deadline_rules SET priority='optional' WHERE is_mandatory=true AND is_optional=true;
UPDATE paliad.deadline_rules SET priority='recommended' WHERE is_mandatory=false;
-- everything else stays default 'mandatory'
```
- Migration 084: backfill `condition_expr` from `condition_flag`:
- `condition_flag IS NULL OR condition_flag = '{}'` → leave `condition_expr` NULL.
- 1 element → `{"flag":"<name>"}`.
- N elements → `{"op":"and","args":[{"flag":"<a>"},{"flag":"<b>"},...]}`.
**Step C — Pipeline C migration**
- Migration 085: data-move from `paliad.event_deadlines` (77 rows) into `paliad.deadline_rules`:
- Each event_deadline → new deadline_rule with:
- `proceeding_type_id = NULL`
- `trigger_event_id = source.trigger_event_id`
- `concept_id` resolved best-effort by `aliases` match against `paliad.deadline_concepts`; NULL if no match.
- `duration_value`, `duration_unit`, `timing` copied (Pipeline C's `working_days` unit, `before` timing, all survive).
- `alt_duration_value`, `alt_duration_unit`, `combine_op` copied.
- `name`, `name_en`, `deadline_notes`, `deadline_notes_en` copied.
- `parent_id = NULL` (Pipeline C has no parent chains).
- `condition_expr = NULL` (Pipeline C has no flags).
- `is_active = true`.
- `legal_source` populated from rule_codes if available.
- Migration 086: backfill any `paliad.deadlines.rule_id` that points at the old `event_deadlines.id` (none today — but defensive). Service-layer code already does fuzzy linkage; this is just future-proofing.
**Step D — Calculator unification**
- Service refactor (no migration): `FristenrechnerService.Calculate` reads `condition_expr` (with fallback to `condition_flag` for unbackfilled rows during the transition window); reads `is_court_set` (with heuristic fallback); reads `priority` (with `is_mandatory`+`is_optional` fallback). Same compatibility shim for `timing='before'` and `working_days`.
- `EventDeadlineService.Calculate` rewires to call the unified calculator with `{trigger_event_id}` filter.
- `POST /api/tools/event-deadlines` continues to work transparently (calls into the unified backend).
**Step E — Drop legacy columns + tables**
- Migration 087: drop `paliad.deadline_rules.condition_flag` (after verifying every rule has either `condition_expr` set or both `condition_flag`+`condition_expr` are NULL).
- Migration 088: drop `paliad.deadline_rules.condition_rule_id` (0 live rows; safe drop).
- Migration 089: drop `paliad.deadline_rules.is_mandatory` + `is_optional` (after `priority` is established).
- Migration 090: drop `paliad.trigger_events` + `paliad.event_deadlines` tables (after all callers route through unified calculator). **DESTRUCTIVE** — flagged in §9 risk; Phase 3 slice §10.6 includes a dry-run + verification gate.
**Step F — Project model soft-merge (Q2)**
- Migration 091: backfill `paliad.projects.proceeding_type_id` from court / metadata where unambiguous (currently 11/11 NULL — likely 0 rows updated; defensive).
- Migration 092: add a CHECK constraint to `paliad.projects.proceeding_type_id` that it points at a `category='fristenrechner'` row. (Doesn't break anything live since all 11 are NULL.)
- Phase 3 slice §10.5 also updates the project-create form's proceeding-type picker to only surface `fristenrechner` codes.
- Litigation codes (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL) **stay in `paliad.proceeding_types`** but become unused for project-binding. They remain reachable via cascade leaves (where ECC.proceeding_type_code is set) for the rare case the cascade routes a query against the litigation rules. **No data loss.**
**Step G — Spawn wiring (Q7)**
- Migration 093: backfill `paliad.deadline_rules.spawn_proceeding_type_id` for the 8 existing `is_spawn=true` rules. Per audit §1.6:
- `APP.app.notice` ← `INF.inf.decision`, `REV.rev.decision`, `CCR.ccr.decision` → spawn_proceeding_type_id = APP's id.
- Etc.
- Service refactor: `FristenrechnerService.Calculate` resolves spawns via global rule index (§6).
**Step H — Instance level (Q9)**
- Service refactor: when project has `instance_level` set, the project's effective proceeding code is derived from `(proceeding_code, instance_level)` lookup:
- `DE_INF + first` → `DE_INF`.
- `DE_INF + appeal` → `DE_INF_OLG`.
- `DE_INF + cassation` → `DE_INF_BGH`.
- `DE_NULL + first` → `DE_NULL`; `DE_NULL + cassation` → `DE_NULL_BGH`. (DE_NULL has no OLG instance — bypassed.)
- `EPA_OPP + appeal` → `EPA_APP`; `EPA_OPP + first` → `EPA_OPP`.
- DPMA flavours similarly.
- Phase 3 slice §10.8 includes a project-detail UI for the picker.
**Step I — Backfill deadline rule_id on legacy data (Q10)**
- Migration 094: one-time fuzzy match. For each `paliad.deadlines` row with `rule_id IS NULL`:
- Lookup `paliad.deadline_concepts.aliases` containing `LOWER(deadlines.title)`.
- If unique match → link to first rule with `concept_id` matching.
- If ambiguous or no match → leave NULL, log to `paliad.deadline_rule_audit` with a `restore` action.
- Targets 25/26 deadlines. Expected match rate: ~5070%.
**Step J — Rule editor goes live (Q5)**
- Multiple migrations (TBD numbering) + frontend work. Phase 3 slice §10.7.
**Net migration count: ~17.** Most are < 50 LoC SQL each.
### 3.2 Cutover ordering — critical invariants
1. **Additive schema lands first.** Calculator reads new columns optionally; old code paths continue.
2. **Backfill before service rewrite.** Service rewrite assumes new columns are populated; backfills run earlier.
3. **Service rewrite before drops.** Service rewrite must successfully consume new columns before legacy columns get dropped.
4. **Drops in dependency order.** `condition_flag` drop comes after all readers cut to `condition_expr`. Pipeline C tables drop last.
5. **Soft-merge (Q2) is last data change.** CHECK constraint on `projects.proceeding_type_id` lands after Phase 3 slice §10.5 updates the form.
6. **Audit log is live BEFORE the rule editor.** Trigger goes in (Step A.079) well before the editor surface (Step J). Any rule edit captured forever.
### 3.3 Rollback strategy
Each migration ships a real `*.down.sql` that's been dry-run tested. Backfills are reversible (the source columns are kept until Step E). The destructive drops (Step E) are gated on a manual go from head — m's escalation path triggers only if a drop would lose data we haven't migrated.
---
## 4. Rule editor architecture (Q5 option C)
m: "C please — I need to see these things. Admin only, ofc."
The editor is the **largest single surface** in Phase 3. ~3-4 PRs of work depending on slicing.
### 4.1 Routes + RLS
| Route | Method | Auth | Purpose |
|---|---|---|---|
| `GET /admin/rules` | GET | `is_global_admin=true` | List all rules. Filterable by proceeding, concept, priority, condition_expr-present, lifecycle_state. |
| `GET /admin/rules/{id}` | GET | global_admin | View a single rule + its parent chain + its children. |
| `GET /admin/rules/{id}/edit` | GET | global_admin | Form view (HTML rendered server-side from rule data). |
| `POST /api/admin/rules/{id}/draft` | POST | global_admin | Create a draft of an existing rule. Sets `lifecycle_state='draft'`, `draft_of=parent.id`. |
| `PATCH /api/admin/rules/{id}` | PATCH | global_admin | Apply changes to a draft. Requires `reason` body field. |
| `POST /api/admin/rules/{id}/publish` | POST | global_admin | Promote draft to published. Archives the prior published version (`lifecycle_state='archived'`). |
| `POST /api/admin/rules/{id}/archive` | POST | global_admin | Archive a published rule. Cascade-check parent_id references. |
| `POST /api/admin/rules` | POST | global_admin | Create a new rule from scratch (starts as `lifecycle_state='draft'`). |
| `GET /admin/rules/{id}/audit` | GET | global_admin | Audit log for this rule. |
| `POST /admin/rules/{id}/preview` | POST | global_admin | Preview-on-trigger-date — runs calculator with this draft replacing its published peer; returns the resulting timeline (no persistence). |
| _(removed t-paliad-297)_ migration-export endpoint | — | — | Was a SQL-export tool generating `*.up.sql` from audit rows. Workflow shifted to hand-written numbered migrations; tool removed in m/paliad#129. |
### 4.2 Draft → published lifecycle
```
[create]
draft ───[edit, validate, preview]───▶ draft
│ [publish] (only if validation green + reason supplied)
published ───[edit creates new draft_of this]───▶ draft (child)
│ │
│ │ [publish replaces parent]
│ ▼
│ [archive] (only if no live parent_id depends on it)
archived
```
**Invariants:**
- A published rule's columns are immutable. Edits create a draft.
- A draft is independent — calculator skips drafts (`is_active=true AND lifecycle_state='published'`).
- On publish: the draft's columns are copied onto the parent's id (preserves FKs from `paliad.deadlines.rule_id`), draft row's `lifecycle_state` flips to `archived`, prior parent row state archives separately into audit log.
- On archive of published: cascade-check children with `parent_id=this`. Reject if any active children.
### 4.3 Form layout
The form's left column is the **identity** (proceeding, concept, trigger event), the right column is the **math + flags**, with a third **lifecycle** column for priority / court-set / spawn. Below the form, a live **preview pane** shows the calculator's output with the draft applied.
Fields are grouped by audit's §1 sections (identity / labels / math / conditional / party / lifecycle). Validation runs on every change and shows inline.
### 4.4 Validation rules (server-side, mirrored client-side)
1. `name` non-empty.
2. `duration_value` ≥ 0; if = 0 → either `is_court_set=true` OR `parent_id` set OR no event_type other than `filing`. (Today's 4-bucket rule.)
3. `duration_unit` in `{days, weeks, months, working_days}`.
4. `timing` in `{after, before}`.
5. `combine_op` in `{NULL, max, min}`; non-NULL requires both base and alt_* set.
6. `parent_id` if set: must point at an active rule in same proceeding (or in a proceeding reachable via spawn).
7. `parent_id` if set: must have lower `sequence_order` (parents before children).
8. `condition_expr`: validate against jsonb schema (recursive descent, flag-names from a known vocabulary).
9. `priority` in `{mandatory, recommended, optional, informational}`.
10. `is_spawn=true` requires `spawn_proceeding_type_id NOT NULL`.
11. `is_court_set=true` AND `duration_value=0` AND `parent_id IS NULL` allowed (top-level court action). `is_court_set=true` AND `duration_value > 0` allowed (court-set anchor with offset chain).
12. `code` if set: unique within `(proceeding_type_id, code)` pair.
13. **Spawn cycle check** on draft → published: walk spawn graph from this rule, abort if cycle reaches this rule.
14. `reason` body field non-empty + ≥ 10 chars.
### 4.5 Preview-on-trigger-date
The preview pane runs the calculator with a synthetic rule corpus where this draft replaces its published peer (or is appended if it's a new rule). Returns a `UIResponse` with the full timeline. The user enters a trigger date + flag set; the preview redraws.
Edge cases:
- If the draft introduces a cycle, preview shows the error inline.
- If the draft references a non-existent `parent_id`, preview shows the parent-resolution error inline.
- If the draft's `condition_expr` is malformed, preview shows the jsonb validator error.
### 4.6 Migration-export (compliance hook)
Rules are legal infrastructure. Live edits must end up in version control or the audit trail decouples from the codebase. The export endpoint solves this:
- Aggregates all audit rows since `migration_exported=true` AND `lifecycle_state IN ('published','archived')`.
- Generates a `*.up.sql` migration with UPDATE / INSERT / soft-delete statements.
- Also generates a matching `*.down.sql`.
- Returns the SQL blob for the human to commit.
- Marks the audit rows `migration_exported=true`.
The migration files don't *automatically* get committed — that's a human step. But the export means every live edit is reproducible from version control after the fact.
### 4.7 The audit trail UX
`GET /admin/rules/{id}/audit` returns a chronologically ordered list of audit rows. Each entry:
- timestamp + actor name
- action (create / update / publish / archive / restore)
- diff view (before_json vs after_json)
- reason text
- `migration_exported` badge
- link to "Revert to this state" (creates a new draft of the rule with the state from this audit row)
### 4.8 What stays out of scope of Phase 3 editor
- Cross-rule bulk edit (e.g. "rename `with_ccr` flag everywhere") — manual SQL or a future v2 feature.
- Rule import from CSV / JSON — manual SQL or future v2.
- Inline preview of the rule on production SmartTimeline projects — preview-on-trigger-date is sufficient.
- Multi-user collaborative editing — single-admin assumption (m).
---
## 5. Event-trigger endpoint — `POST /api/tools/event-trigger`
Q4 — preserves Pipeline C's contract through the unified model.
### 5.1 Contract
**Request:**
```json
{
"event_type_slug": "upc_oral_hearing",
"trigger_date": "2026-06-15",
"court_id": "upc-ld-paris",
"flags": ["with_ccr"]
}
```
- `event_type_slug` (required) — points at `paliad.event_types.slug`.
- `trigger_date` (required) — ISO date.
- `court_id` (optional) — picks the holiday-calendar regime; defaults to UPC München for UPC-jurisdiction event types, DE for DE/EPA/DPMA.
- `flags` (optional) — same as `FristenrechnerService.Calculate`'s `Flags`.
**Response:**
```json
{
"event_type": {"slug": "upc_oral_hearing", "label_de": "Mündliche Verhandlung", "category": "hearing"},
"trigger_date": "2026-06-15",
"deadlines": [
{
"ruleId": "...",
"code": "rop151.cost_app",
"name": "Antrag auf Kostenentscheidung",
"ruleRef": "RoP.151",
"dueDate": "2026-07-16",
"originalDate": "2026-07-15",
"wasAdjusted": true,
"isMandatory": true,
"party": "both",
...
},
...
]
}
```
Same `UIDeadline` shape as `POST /api/tools/fristenrechner`.
### 5.2 Resolution flow
1. Look up `event_type` by slug. If `proceeding_type_code` is encoded via `paliad.deadline_concept_event_types(jurisdiction=…)`, resolve forward.
2. Find all rules where `trigger_event_id = event_type.trigger_event_id` (a new join — the migration in §3 Step C populates this).
3. Plus: find all rules where `concept_id` matches event_type's default concept (from `deadline_concept_event_types`), in case the migration didn't cover all event_types.
4. Calculator runs on the rule set with `triggerDate` + `flags`.
### 5.3 Why this matters
The cascade UI already lets the user click a leaf → see linked concepts → see rules. After this endpoint:
- The cascade can offer "I just logged this event — calculate its deadlines now" with one click.
- Email-parsing flows (future Phase H Frist-Extraktion) call this endpoint directly when they detect an event_type.
- The "Was kommt nach…" tab on Pathway A migrates to this endpoint (replaces `EventDeadlineService` call).
### 5.4 Edge cases
- **No matching rules** → return empty `deadlines` array (not 404). Caller can show "Keine Fristen abgeleitet" inline.
- **Multiple matching trigger_event_ids** → calculator merges per `sequence_order`. Deduplication on `(rule_id)`.
- **Project context** (caller passes `project_id`) — defer to v2; today's wizard semantics work without it.
---
## 6. Cross-proceeding spawn wiring
Q7 — replaces the "We don't have that rule in our map" half-wired state from `projection_service.go:896-901`.
### 6.1 The global rule index
Today: `DeadlineRuleService.List(proceedingTypeID *int)` returns rules of one proceeding. The calculator's `ruleByID` map (in `FristenrechnerService.Calculate`) only contains those.
After Phase 3 slice §10.4:
```go
type GlobalRuleIndex struct {
byID map[uuid.UUID]models.DeadlineRule
byCode map[string]models.DeadlineRule // (proceeding_code . rule_code)
bySpawnTarget map[int][]uuid.UUID // proceeding_type_id → rules whose spawn_proceeding_type_id = this
}
func (s *DeadlineRuleService) GlobalIndex(ctx context.Context) (*GlobalRuleIndex, error) {
// One SELECT, hydrate all active rules with proceeding_type metadata.
}
```
The calculator builds a `GlobalRuleIndex` once at request start; spawn resolution becomes O(1) lookup.
### 6.2 Spawn execution
When the calculator hits a rule with `is_spawn=true`:
1. Resolve `spawn_proceeding_type_id` → target proceeding code.
2. Append the target proceeding's root rule chain to the response, computing offsets off the spawning rule's date (which is the parent's anchor for the spawned chain).
3. Mark spawned rows with `SpawnedFrom: <source_rule_code>` in the response so the frontend can render the spawn boundary clearly.
### 6.3 Cycle guard
A pre-flight check during `Calculate()`:
- Walk spawn graph from the target proceeding.
- If any reachable proceeding is the **starting** proceeding, abort with `ErrCyclicSpawn`.
- Logged + surfaced as an error in the response (not silent).
### 6.4 SmartTimeline integration
After spawn wiring lands, `ProjectionService.computeProjections` resolves cross-proceeding rules correctly. The dependency-annotation graph (DependsOnRuleCode / Date / Name) crosses proceeding boundaries cleanly.
---
## 7. Instance-level on `paliad.projects`
Q9 — DE_INF → DE_INF_OLG → DE_INF_BGH ladder without manual proceeding_type re-pick.
### 7.1 Schema
```sql
ALTER TABLE paliad.projects ADD COLUMN instance_level text;
ALTER TABLE paliad.projects ADD CONSTRAINT projects_instance_level_check
CHECK (instance_level IS NULL OR instance_level IN ('first', 'appeal', 'cassation'));
```
### 7.2 Mapping table (in code, not DB)
```go
// internal/services/instance_mapping.go
func ResolveInstanceCode(baseCode string, level string) string {
if level == "" || level == "first" {
return baseCode // first instance uses the base code as-is
}
switch baseCode + "+" + level {
case "DE_INF+appeal": return "DE_INF_OLG"
case "DE_INF+cassation": return "DE_INF_BGH"
case "DE_NULL+cassation": return "DE_NULL_BGH"
case "EPA_OPP+appeal": return "EPA_APP"
case "DPMA_OPP+appeal": return "DPMA_BPATG_BESCHWERDE"
case "DPMA_OPP+cassation":return "DPMA_BGH_RB"
case "DPMA_BPATG_BESCHWERDE+cassation":return "DPMA_BGH_RB"
// UPC has no instance ladder today — UPC_CFI / UPC_APP are distinct proceedings.
default: return baseCode // unknown combination — degrade
}
}
```
### 7.3 Auto-advance
When a Berufungsschrift / Revisionsschrift fires on the actuals side (a `paliad.deadlines` or `paliad.project_events` row is created with the right concept), the project's `instance_level` advances automatically. Phase 3 slice §10.8 includes the auto-advance logic with a manual override.
### 7.4 UI
Project-detail page gets a small "Verfahrensstand" picker:
- "Erste Instanz" / "Berufung" / "Revision"
- A subtle hint "auto-advanced am 2026-06-12 vom inf.appeal" when the system advanced.
- A manual override.
### 7.5 Why this isn't free with proceeding_type_id alone
Today the project model conflates "what kind of case is it" (the base proceeding) with "where in the ladder are we" (the instance). A user filing a Berufung today must manually pick `DE_INF_OLG` instead of `DE_INF` — the model loses the first-instance history. After this column, the project carries both base + level, and the SmartTimeline / calculator can derive the right rule set.
---
## 8. Frontend ripples
Five consumers need changes after the rule model unifies. Each lands in its own Phase 3 slice (or shares one if small).
### 8.1 `/tools/fristenrechner` Pathway A — wizard timeline
- **`frontend/src/fristenrechner.tsx`** — proceeding-type picker continues to surface `category='fristenrechner'` codes (no change in source; soft-merge means litigation codes are unused on projects but the picker query already filters by category).
- **`frontend/src/client/fristenrechner.ts`** — the `renderTimelineBody` + `renderColumnsBody` renderers consume `UIDeadline` which gains:
- `priority: 'mandatory'|'recommended'|'optional'|'informational'` (replaces `isMandatory`+`isOptional`).
- `isCourtSet: bool` (now from real column; semantic identical).
- Renderer maps `priority` to badge colours per §2.3 table.
### 8.2 `/tools/fristenrechner` Pathway B — cascade + calculate-rule card
- **Cascade UI** — unchanged. The taxonomy → concept → rule chain stays.
- **`calculate-rule` card** — reads `UIDeadline` from `/api/tools/fristenrechner/calculate-rule`; same `priority` rename.
- **B2 search results** — concept-cards continue to render rule pills. Pill badge updates for `priority`.
### 8.3 `/tools/fristenrechner` "Was kommt nach…" tab (Pipeline C migrates)
- **`frontend/src/client/fristenrechner.ts:833`** — switches from `/api/tools/event-deadlines` to `/api/tools/event-trigger` (new endpoint per §5). Response shape is the unified `UIDeadline` array. Local rendering needs no change beyond the `priority` rename.
- **Trigger-event picker** — switches from `paliad.trigger_events` (110 rows) to `paliad.event_types` (45 rows, growing). The new vocabulary is finer-grained; a per-jurisdiction filter chip helps users pick.
### 8.4 SmartTimeline (`/projects/{id}` Verlauf + `/projects/{id}/chart`)
- **`internal/services/projection_service.go`** — already calls `FristenrechnerService.Calculate`. After unification, no public-API change. The cross-proceeding spawn rows now resolve correctly (Q7).
- **`frontend/src/client/views/shape-timeline.ts` + `shape-timeline-chart.ts`** — the `TimelineEvent` struct gains optional `SpawnedFrom` field; renderer can show a spawn-boundary divider.
- **Dependency annotations** — already in the wire shape (`DependsOnRuleCode/Date/Name`); now resolve across proceedings.
### 8.5 `/tools/verfahrensablauf` (abstract-browse surface)
- **`frontend/src/client/views/verfahrensablauf-core.ts`** — pure-functional, consumes `UIDeadline`. Same `priority` rename, otherwise no change.
- Variant chips (Slice 3 of t-paliad-178, not yet shipped) consume `condition_expr` flag names; the chip strip surfaces top-level flags from `condition_expr`'s vocabulary. AND of selected chips becomes the `Flags` payload to the calculator.
### 8.6 Project-detail (`/projects/{id}`) — `instance_level` picker
- **`frontend/src/projects-detail.tsx`** + **`client/projects-detail.ts`** — new picker (Slice §10.8). Auto-advance toast.
### 8.7 Admin surface (`/admin/rules`) — net-new
- All-new TSX + client + CSS for the rule editor (§4). Lands in §10.7 (data-model and audit-log slices) and §10.11 (editor UI slice). Heaviest single Phase 3 deliverable.
---
## 9. Risk + tradeoffs
### 9.1 Destructive migrations
**Step E** drops `paliad.event_deadlines`, `paliad.trigger_events`, `paliad.deadline_rules.condition_flag`, `condition_rule_id`, `is_mandatory`, `is_optional`. Each drop is gated on a manual verification step. If verification fails, head escalates to m via otto/head delegation (telegram + pwa-form).
**Mitigation:**
- Every drop has a backup snapshot taken pre-drop (CSV export to S3 or in-DB archive table for 30 days).
- Drops are batched in a single migration window (single deploy), so rollback is one revert.
- Each drop has a paired `*.down.sql` that recreates from the snapshot.
### 9.2 Audit-log compliance gap during cutover
The audit log table lands in Step A; the rule editor in Step J. **Between A and J, rules can be edited via SQL migrations** (the existing workflow). Those edits will be captured by the DB trigger but the `reason` field requires session-level setting.
**Mitigation:**
- Phase 3 slice §10.1 includes a hook in the migration tooling: each migration that touches `paliad.deadline_rules` must call `SET LOCAL paliad.audit_reason = 'migration: <name>'` before its UPDATE statements.
- A pre-commit hook checks for this in `internal/db/migrations/*.up.sql`. The dev workflow is mostly automated.
### 9.3 Cross-corpus drift during the cutover
Between Step C (`event_deadlines` migrated into `deadline_rules`) and Step E (Pipeline C tables dropped), two parallel data corpuses exist. If someone edits `event_deadlines` during this window, the change DOESN'T propagate to `deadline_rules`.
**Mitigation:**
- Step C migration also installs a DB trigger on `paliad.event_deadlines` that fires `RAISE EXCEPTION 'event_deadlines is read-only during migration window; edit paliad.deadline_rules instead'`. The trigger lives until Step E drops the table.
- The trigger is documented in the migration file; Step E removes it cleanly.
### 9.4 `condition_expr` jsonb performance
Today's `condition_flag text[]` GIN-indexable. `jsonb` is queryable but the predicates we care about (recursive eval) aren't indexable cleanly. **Each rule evaluation walks the expression tree.**
**Mitigation:**
- 172 rules × per-request eval is trivial (~10µs per rule). No perceptible perf cost.
- If future scaling is concerned: keep a `condition_flag` materialised column (text[]) for the simple AND-only case + flag-based GIN index for batch filters. Not needed for v1.
### 9.5 Migration-export is a manual step
The compliance hook (§4.6) generates the migration SQL but doesn't automatically commit it. If m forgets to export + commit after editing rules, the audit log diverges from version control.
**Mitigation:**
- The editor UI surfaces a banner: "Migration-Export ausstehend: N unexported audit entries". Banner persists until export runs.
- A cron job in `mai` flags it weekly if the unexported count grows.
### 9.6 Soft-merge friction
After Step F, project-create form shows 19 fristenrechner codes instead of 7 litigation codes. The picker is denser; some users may pick the wrong granularity (e.g. UPC_INF when they should pick UPC_PI).
**Mitigation:**
- Picker gets jurisdiction-group headers (UPC / DE / EPA / DPMA) and short descriptions.
- The existing court field stays as a separate hint.
- Frontend slice §10.5 includes UX polish.
### 9.7 Spawn cycle false positives
A legitimate Re-establishment → original-proceeding spawn might LOOK like a cycle but isn't (the spawned proceeding is a NEW instance, not the original). The cycle guard might over-trigger.
**Mitigation:**
- Cycle detection walks the **graph of proceeding codes**, not instances. A→B→A on proceeding codes IS a cycle.
- Re-establishment isn't a spawn into the same proceeding — it's a wholly separate calculator call from the user side. Not affected.
### 9.8 Instance-level + spawn interaction
What if a project at `instance_level='appeal'` (DE_INF_OLG) has an inf.appeal-style spawn that points at... DE_INF_BGH? The spawn semantic is "spawn the appeal proceeding"; with instance-level resolution, that's already what the user is in.
**Mitigation:**
- Spawn resolution happens against the BASE proceeding code, not the instance-resolved code. So `DE_INF` rules carry `spawn_proceeding_type_id=DE_INF_OLG`; when a project is at DE_INF_OLG already, the calculator skips that spawn (already at the spawned target).
- If the project is at DE_INF_BGH (cassation), spawn from DE_INF.decision is suppressed (you're past it).
### 9.9 Backfill ambiguity for `rule_id` fuzzy match (Step I)
The migration tries to link 25/26 legacy free-text deadlines to rules. ~3050% will be unambiguous (e.g. "Klageerwiderung" → 1 candidate per jurisdiction × project). Others will be ambiguous.
**Mitigation:**
- Migration leaves ambiguous cases NULL.
- A follow-up UI on `/deadlines/{id}` lets users manually pick the rule. Phase 3 slice §10.6 includes this.
---
## 10. Slicing for Phase 3 — 12 prioritized slices
Each slice is independently mergeable + shippable + reverttable. Ordering: **data-model foundation first → consumer-side ripples → rule-editor surface last.**
### Slice 1 — Additive schema (Step A)
**Migrations 078081.**
Adds columns to `paliad.deadline_rules` (`trigger_event_id`, `spawn_proceeding_type_id`, `combine_op`, `condition_expr`, `priority`, `is_court_set`, `lifecycle_state`, `draft_of`, `published_at`). Creates `paliad.deadline_rule_audit`. Adds `paliad.projects.instance_level`. **No data change.**
Go: tests confirm schema lands; calculator still reads old columns (compat mode).
**Implementer effort:** ~150 LoC SQL + 50 LoC Go model updates + tests. Single PR.
### Slice 2 — Backfill (Step B)
**Migrations 082084.**
Backfill `is_court_set`, `priority`, `condition_expr`. Each backfill is idempotent and logs delta count.
Go: read-side service code stays compat-mode (reads new columns with fallback to old).
**Implementer effort:** ~100 LoC SQL + verification scripts. Single PR.
### Slice 3 — Pipeline C migration (Step C)
**Migrations 085086 + read-only trigger.**
Data-move from `paliad.event_deadlines` → `paliad.deadline_rules` (77 rows). Installs the read-only trigger on `paliad.event_deadlines` to prevent edits during cutover.
Go: `EventDeadlineService.Calculate` rewires to call `FristenrechnerService.Calculate` with `{trigger_event_id}` filter.
**Implementer effort:** ~200 LoC SQL + 100 LoC Go + integration test for the unified call path. Single PR.
### Slice 4 — Calculator unification (Step D)
**No migration; service refactor only.**
`FristenrechnerService.Calculate` reads `condition_expr` (with fallback), `is_court_set` column (with fallback), `priority` (with fallback). Handles `timing='before'`, `working_days`, `combine_op`. Spawn resolution uses GlobalRuleIndex.
`POST /api/tools/event-deadlines` continues to work; routes into unified.
**Implementer effort:** ~300 LoC Go + heavy test coverage (the calculator's behaviour is critical). Single PR.
### Slice 5 — Project model soft-merge (Step F, Q2)
**Migration 091092 + frontend.**
Optional backfill of `paliad.projects.proceeding_type_id` (none today, defensive). CHECK constraint on `paliad.projects.proceeding_type_id` for `category='fristenrechner'`.
Frontend: project-create form's proceeding-type picker shows 19 fristenrechner codes with jurisdiction headers.
**Implementer effort:** ~50 LoC SQL + ~80 LoC frontend + i18n. Single PR.
### Slice 6 — Event-trigger endpoint (Q4)
**Handler + service + frontend.**
`POST /api/tools/event-trigger` per §5. Rewires `frontend/src/client/fristenrechner.ts:833` to call this instead of `/api/tools/event-deadlines`. Trigger-event picker switches to `paliad.event_types`.
**Implementer effort:** ~100 LoC Go handler + 50 LoC service helper + 150 LoC frontend + tests. Single PR.
### Slice 7 — Spawn wiring (Step G, Q7)
**Migration 093 + service.**
Backfill `spawn_proceeding_type_id` on 8 live `is_spawn=true` rules. Service: `GlobalRuleIndex` + spawn resolution + cycle guard.
Frontend: SmartTimeline renders spawn boundary divider (small CSS + render tweak).
**Implementer effort:** ~50 LoC SQL + 200 LoC Go (index + spawn execution + cycle check + tests) + 50 LoC frontend. Single PR.
### Slice 8 — Instance level (Step H, Q9)
**Migration 080 (already in Slice 1) + service + frontend.**
`instance_level` resolution helper. Auto-advance logic on deadline / event triggers. Project-detail picker UI.
**Implementer effort:** ~80 LoC Go + 120 LoC frontend (picker + auto-advance toast) + tests. Single PR.
### Slice 9 — Drop legacy columns + tables (Step E)
**Migrations 087090.**
Drop `condition_flag`, `condition_rule_id`, `is_mandatory`, `is_optional`. Drop `paliad.trigger_events`, `paliad.event_deadlines` (after Slice 6 cuts the last reader). Verification gate; backup snapshots.
**Implementer effort:** ~80 LoC SQL + verification scripts. Single PR. **DESTRUCTIVE — head escalates to m if verification fails.**
### Slice 10 — `rule_id` backfill on legacy deadlines (Step I, Q10)
**Migration 094.**
One-time fuzzy match. Targets 25/26 deadlines. Ambiguous cases left NULL.
Frontend: `/deadlines/{id}` gains a manual "Regel zuordnen" picker for the ambiguous tail.
**Implementer effort:** ~100 LoC SQL (with `paliad.deadline_concepts.aliases` JOIN) + 80 LoC frontend. Single PR.
### Slice 11 — Rule editor (Q5C) — admin surface + audit-log UI
**Handlers + service + frontend.**
Routes per §4.1. Audit log table is already in place from Slice 1. Draft → published lifecycle + validation. Preview-on-trigger-date. Migration-export endpoint. Audit UI.
**Implementer effort:** ~400 LoC Go (handler + service + validation + migration exporter) + ~700 LoC frontend (form, preview pane, audit timeline) + ~150 LoC CSS + i18n + tests. **2 sub-PRs:**
- 11a: backend + read-only `/admin/rules` listing + audit view.
- 11b: editor form + validation + preview + migration-export.
**Heaviest slice.** Lands last so schema is stable.
### Slice 12 — Orphan concept seed (Q8)
**Multiple small migrations + legal review.**
Per the order: `wiedereinsetzung > schriftsatznachreichung > versaeumnisurteil-einspruch > weiterbehandlung > others`. Each concept gets 1-3 rules per applicable jurisdiction. Per-concept legal review before merging.
**Implementer effort:** ~30 LoC SQL × 9 concepts = ~270 LoC. Legal review gates each. **Lands after editor (Slice 11)** so future seed work happens through the editor, not direct migrations.
### Recap — slice ordering
```
foundation: 1 → 2 → 3 → 4 (schema + backfill + Pipeline C move + calculator unification)
consumers: 5 → 6 → 7 → 8 (project model + event-trigger + spawn + instance-level)
cleanup: 9 → 10 (drop legacy + backfill rule_id)
admin surface: 11a → 11b (rule editor backend + frontend)
content: 12 (orphan concept seed)
```
Slices 14 are sequentially dependent (each depends on the prior schema/backfill state). Slices 58 can run in parallel after Slice 4 (independent consumers). Slice 9 needs all readers cut over. Slice 10 is independent — can run any time after Slice 3. Slice 11 needs schema stability (Slice 1 + 9 done). Slice 12 needs the editor (Slice 11) — or could run before via SQL if head prefers.
---
## 11. Files the implementer will touch (Slice 1 only)
Per the brief, only the FIRST slice's file map gets enumerated. Subsequent slices land their own file maps.
### Migrations
- `internal/db/migrations/078_unified_rule_columns.up.sql` (NEW, ~80 LoC)
- `internal/db/migrations/078_unified_rule_columns.down.sql` (NEW)
- `internal/db/migrations/079_deadline_rule_audit.up.sql` (NEW, ~60 LoC — table + RLS + trigger)
- `internal/db/migrations/079_deadline_rule_audit.down.sql` (NEW)
- `internal/db/migrations/080_projects_instance_level.up.sql` (NEW, ~10 LoC)
- `internal/db/migrations/080_projects_instance_level.down.sql` (NEW)
### Go models
- `internal/models/deadline_rule.go` — add fields: `TriggerEventID`, `SpawnProceedingTypeID`, `CombineOp`, `ConditionExpr (json.RawMessage)`, `Priority`, `IsCourtSet`, `LifecycleState`, `DraftOf`, `PublishedAt`. Keep `IsMandatory` + `IsOptional` + `ConditionFlag` as compat fields.
- `internal/models/project.go` — add `InstanceLevel *string`.
- `internal/models/deadline_rule_audit.go` (NEW, ~30 LoC) — audit row struct.
### Go services (read-side fallback compat)
- `internal/services/deadline_rule_service.go` — extend `ruleColumns` const to include new columns; column list reflects the dual-shape during transition. ~10 LoC.
### Tests
- `internal/services/deadline_rule_service_test.go` — assert SELECT returns new columns. Pure DB sanity.
- `internal/db/migrations_test.go` (if exists) — assert schema lands cleanly. Smoke.
**Net Slice 1 LoC: ~250 SQL + 100 Go.**
Single PR. Reviewable end-to-end. Mergeable independently.
---
## 12. Open questions for HEAD (not m)
Sub-decisions head will resolve at slice-start. None block this design's approval; all are tactical.
### Q-H-1 — Migration window for Slice 9 (destructive drops)
Slice 9 drops `paliad.trigger_events` + `paliad.event_deadlines` + 4 deadline_rules columns. Should it deploy:
- (a) During a maintenance window (announce 24h prior, ~10min downtime expected).
- (b) Live during normal hours (zero-downtime; no migration locks the table).
- (c) Behind a feature flag (no drop; columns renamed to `_legacy` for a grace period).
Recommended: (a). Live edits during a destructive migration are stress.
### Q-H-2 — Rule editor's draft lifecycle for v1
The full lifecycle is draft → published → archived with audit trail. v1 simplification options:
- (a) Full lifecycle as designed.
- (b) Skip the draft layer initially — edits go straight to published with audit-only trail.
- (c) Draft is in-memory only — no DB row until publish.
Recommended: (a). Audit traceability requires DB rows; the draft FK is the smallest extra cost for the largest reviewability win.
### Q-H-3 — Audit-log retention
How long to retain `paliad.deadline_rule_audit` rows?
- (a) Forever (legal-archive grade).
- (b) 7 years (typical legal retention).
- (c) 2 years rolling + annual export to S3.
Recommended: (a). Rules are legal infrastructure; the audit table is small (~hundreds of rows/year max).
### Q-H-4 — Preview-on-trigger-date data isolation
The preview endpoint runs the calculator with a draft replacing its published peer. Implementation options:
- (a) Pass the draft as an in-memory rule override to the calculator (calculator gains an `Overrides []models.DeadlineRule` parameter).
- (b) Temporary materialised view that combines published + draft, scoped to the request.
- (c) Run inside a DB transaction with the draft applied + rolled back.
Recommended: (a). Cheapest, deterministic, no concurrency surface.
### Q-H-5 — Migration-export format
The export endpoint emits SQL. Format options:
- (a) Pure SQL (UPDATE / INSERT / DELETE statements).
- (b) SQL wrapped in a Go migration helper that uses application-layer types (more readable but more code).
- (c) JSON dump + apply-tool (decouples from SQL syntax).
Recommended: (a). The existing migration corpus is pure SQL; consistency wins.
### Q-H-6 — Slice 6 (event-trigger) timing vs Slice 4 (calculator unification)
Slice 6 builds the new endpoint on top of the unified calculator. If Slice 4 isn't done, Slice 6 routes against the compat-mode calculator. Both work, but Slice 4 has more test coverage.
- (a) Strict ordering — Slice 6 only after Slice 4.
- (b) Allow parallel — Slice 6 uses the partial unified calc.
Recommended: (a). Calculator is critical; serial.
### Q-H-7 — Slice 7 (spawn) cycle-guard strictness
Today no live cycles exist. The cycle guard prevents future bad data. Options:
- (a) Reject on insert/update (CHECK constraint with PL/pgSQL function).
- (b) Reject at calculator runtime only.
- (c) Both.
Recommended: (c). Defense-in-depth. The CHECK constraint catches editor mistakes; runtime guard handles corrupted state.
### Q-H-8 — Instance-level (Slice 8) UI placement
The picker can live in:
- (a) Project-detail header bar.
- (b) A "Verfahrensstand" mini-section in the sidebar.
- (c) Inline in the proceeding-type display ("UPC_INF · Berufung").
Recommended: (a). Highest discoverability.
### Q-H-9 — Slice 11 (rule editor) UX testing scope
Before shipping the editor, what testing surfaces?
- (a) Unit tests + Playwright smoke (load form, edit, publish, audit).
- (b) Internal dogfood with m on staging.
- (c) Both.
Recommended: (c). Editor is admin-only; m IS the dogfood user; pair-prog sessions during slice work.
### Q-H-10 — Migration 094 ambiguity tail (Slice 10)
The fuzzy-match backfill leaves ambiguous deadlines NULL. How to surface for cleanup?
- (a) Inline picker on `/deadlines/{id}` only when `rule_id IS NULL` and confidence < threshold.
- (b) Dedicated `/admin/deadlines/needs-linking` queue.
- (c) Skip — leave NULL forever; future-deadline rule_id capture handles new flows.
Recommended: (a). Smallest UX surface; surfaces naturally as the user opens deadlines.
### Q-H-11 — Orphan concept seed (Slice 12) ordering vs editor (Slice 11)
Either:
- (a) Seed via SQL migrations before the editor lands (faster coverage).
- (b) Seed via the editor after it lands (proves the editor works end-to-end).
- (c) Mixed — easy concepts via SQL, complex via editor.
Recommended: (b). Editor needs a real-world workout; orphan-concept rule authoring IS the workout.
### Q-H-12 — Telemetry for the migration
Should each slice deploy include metrics dashboards (e.g. "rules with `priority` set" / "calls to `/api/tools/event-trigger`")? Otherwise migration progress is invisible.
- (a) Yes, add ~3-5 Grafana panels per slice.
- (b) No, rely on existing dashboards.
- (c) Add only for Slices 4 (calculator unification) and 9 (destructive drops).
Recommended: (c). Focused on high-risk slices.
---
## DESIGN READY FOR REVIEW
Inventor (pauli) parks after this commit — head gates the design → Phase 3 transition. No m-gate (autonomy mandate 2026-05-15 00:01).
Recommended Phase 3 implementer rotation: pattern-fluent Sonnet coder per slice. Slices 14 sequential, 58 parallel, 910 cleanup, 11a→11b editor surface, 12 orphan seed. **NOT cronus per memory directive 2026-05-06.**
When picking up the next slice the implementer should:
1. Read this doc end-to-end.
2. Cross-reference the audit (`docs/audit-fristen-logic-2026-05-13.md` on main @ 79f6be3).
3. Check live state in §0 for any drift.
4. Lock the slice's §12 head-decision via mai before starting.
— pauli