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

60 KiB
Raw Blame History

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.

  • 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.gopaliad.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.

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=falsemandatory (~155 rules).
  • is_mandatory=true, is_optional=trueoptional (~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:

{
  "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:

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)

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:
    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.noticeINF.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 + firstDE_INF.
    • DE_INF + appealDE_INF_OLG.
    • DE_INF + cassationDE_INF_BGH.
    • DE_NULL + firstDE_NULL; DE_NULL + cassationDE_NULL_BGH. (DE_NULL has no OLG instance — bypassed.)
    • EPA_OPP + appealEPA_APP; EPA_OPP + firstEPA_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:

{
  "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:

{
  "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:

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

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)

// 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_deadlinespaliad.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