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).
1079 lines
60 KiB
Markdown
1079 lines
60 KiB
Markdown
# 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 075–077 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: ~50–70%.
|
||
|
||
**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. ~30–50% 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 078–081.**
|
||
|
||
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 082–084.**
|
||
|
||
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 085–086 + 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 091–092 + 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 087–090.**
|
||
|
||
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 1–4 are sequentially dependent (each depends on the prior schema/backfill state). Slices 5–8 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 1–4 sequential, 5–8 parallel, 9–10 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
|