Compare commits
13 Commits
mai/knuth/
...
mai/curie/
| Author | SHA1 | Date | |
|---|---|---|---|
| c4c0a82abb | |||
| 5ab14f8b37 | |||
| d1d0cf9c1d | |||
| 5f0a85fa83 | |||
| 6e585951ee | |||
| 8240717b5a | |||
| 593e6243e0 | |||
| 15cc5e418c | |||
| abf0328dcd | |||
| cc13a5b857 | |||
| abef74fe63 | |||
| 49ddaa4eb8 | |||
| 1bd2ebb4ae |
@@ -421,7 +421,7 @@ The editor is the **largest single surface** in Phase 3. ~3-4 PRs of work depend
|
||||
| `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). |
|
||||
| `POST /admin/rules/export-migration` | POST | global_admin | Export pending (draft + audit-since-last-export) rules as a `*.up.sql` blob the human can paste into `internal/db/migrations/`. Sets `migration_exported=true` on the audit rows. |
|
||||
| _(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
|
||||
|
||||
|
||||
1144
docs/design-litigation-planner-2026-05-26.md
Normal file
1144
docs/design-litigation-planner-2026-05-26.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ A full org export today is **< 600 rows of user content** plus reference data
|
||||
|
||||
**Audit trail.** Lives in `paliad.project_events` (93 rows). One row per lifecycle event with `event_type`, `metadata jsonb`, `event_date`, `created_by`. The auditing union (`AuditService.ListEntries`) joins 5 sources (project_events, partner_unit_events, deadline_rule_audit, policy_audit_log, reminder_log). For the export we treat `project_events` as primary; the four auxiliary logs are scope-specific.
|
||||
|
||||
**Existing export precedent.** `/admin/rules/export` + `/admin/api/rules/export-migrations` (handlers/admin_rules.go) — admin-gated, streams a generated SQL artifact. Same shape as what we want for the Excel exports. Re-use the gating helper.
|
||||
**Existing export precedent.** _(Originally pointed at the admin rule-migration export. That tool was deleted in m/paliad#129 / t-paliad-297. The gating pattern — `adminGate(users, …)` on a download endpoint that streams a generated artifact — still lives on other admin handlers, e.g. `handleAdminDownloadBackup` for `/api/admin/backups/{id}/file`.)_ Re-use the gating helper.
|
||||
|
||||
**No Go xlsx library on `go.mod` today.** This design picks **`github.com/xuri/excelize/v2`** in §3.
|
||||
|
||||
@@ -591,7 +591,7 @@ No other slice deltas. v1 still ships slices 1+2+3.
|
||||
- `docs/design-data-model-v2.md` — projects + mandanten + ltree path + can_see_project predicate.
|
||||
- `docs/design-approval-policy-ui-2026-05-07.md` — 5-source audit union (this design adds the 6th source).
|
||||
- `docs/design-profession-vs-project-role-2026-05-07.md` — profession ladder for the §4 project gate.
|
||||
- `internal/handlers/admin_rules.go:303` — `handleAdminExportRuleMigrations` (precedent for admin-gated export-as-download).
|
||||
- `internal/handlers/backups.go` — `handleAdminDownloadBackup` (precedent for admin-gated artifact download; the older rule-migration export precedent was removed in t-paliad-297).
|
||||
- `internal/services/project_service.go:15` — visibility predicate.
|
||||
- `internal/services/derivation_service.go` — `EffectiveProjectRole` for the project gate.
|
||||
- `github.com/xuri/excelize/v2` — chosen xlsx library.
|
||||
|
||||
277
docs/design-procedural-events-b0-findings-2026-05-26.md
Normal file
277
docs/design-procedural-events-b0-findings-2026-05-26.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Slice B.0 — Live DB re-validation findings (t-paliad-273)
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-26
|
||||
**Branch:** `mai/curie/researcher-slice-b-zero`
|
||||
**Predecessor:** `docs/design-procedural-events-model-2026-05-25.md` (cronus, t-paliad-262)
|
||||
**Scope:** READ-ONLY re-validation of the design doc's §1 premises against the live youpc Supabase `paliad` schema. No migration SQL written, no writes to `deadline_rules` or any table. B.1 (additive migration) remains blocked pending m's greenlight.
|
||||
|
||||
This document does **not** redesign the schema. It does **not** propose new structural changes. It records what the live DB looks like ~24 hours after the design was authored, flags every claim that drifted, and gives the eventual B.1 coder a current-as-of-2026-05-26 baseline to plan against.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
The design doc's §1 premises were sound on 2026-05-25. **All numeric premises drifted in the 24 hours since.** The qualitative model (`deadline_rules` conflates three concepts; live `deadlines.rule_id` FK; snapshot precedent established; no `proceeding_event*` tables) still holds.
|
||||
|
||||
The Q5 default ("10 archived multi-row submission_codes collapse safely") is now **moot**: those rows were removed from the live DB between 2026-05-25 15:30 and 2026-05-26 13:30. There are now **zero** multi-row submission codes; every active submission_code maps 1:1 to one rule row. B.1 backfill no longer needs the multi-row collapse logic that §5 of the design doc anticipated.
|
||||
|
||||
The Q6 default ("concept_id attaches to procedural event, not sequencing rule") is **directionally correct but needs refinement**. The empirical attachment is **above** the procedural-event level — `deadline_concepts` rows cluster legal meaning *across* jurisdictional procedural-event variants. One concept_id can span 15 distinct submission_codes (e.g. "Berufungsfrist" across BGH / BPatG / LG / OLG for both PatG and ZPO paths). The FK in §4.1's draft schema (`procedural_events.concept_id REFERENCES deadline_concepts(id)`, N:1) is **already correctly shaped** for this — no schema change needed. The verbal claim in the design doc should be tightened to "one `deadline_concept` row may be referenced by many procedural events; the FK lives on `procedural_events`."
|
||||
|
||||
Migration tracker drift: the design's "next available mig = 124" is stale; live head is 133 (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27 — applied **after** the design was written). **Next available is 134.** Ten migrations landed since the doc was authored — 124..133. None of them touched `deadline_rules` schema, but they did mutate row content (the missing 23 rows and the new event_type/legal_source distribution come from migs 127/128/132/133).
|
||||
|
||||
The design's claimed migration tracker `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 native counter (stuck at v106). The **canonical** tracker is `paliad.applied_migrations` (one row per applied migration, with checksum + applied_at). `internal/db/migrate.go:9-21` is the source of truth. Project CLAUDE.md still says `paliad.paliad_schema_migrations`; that's a stale doc, not a B.0-scope fix.
|
||||
|
||||
One doc-side bug fixed by this slice: design doc §1 + m/paliad#93 issue body referenced `paliad.deadlines.deadline_rule_id`. Live column is `paliad.deadlines.rule_id`. Both files patched on this branch.
|
||||
|
||||
---
|
||||
|
||||
## §1 Headline-count drift table
|
||||
|
||||
All numbers taken 2026-05-26 ~13:30 UTC against the live `paliad` schema.
|
||||
|
||||
| Metric | Design (2026-05-25) | Live (2026-05-26) | Δ | Notes |
|
||||
|---|--:|--:|--:|---|
|
||||
| `deadline_rules` row count | 254 | **231** | -23 | All rows `is_active = true`. No soft-deletes in flight. |
|
||||
| Rows with `submission_code` | 177 | **153** | -24 | |
|
||||
| Distinct `submission_code` values | 158 | **153** | -5 | **All 5 lost are the multi-row `_archived_litigation.*` codes** — see §2. |
|
||||
| Rows with `legal_source` | 102 | **112** | +10 | |
|
||||
| Distinct `legal_source` values | 70 | **87** | +17 | New jurisdictional variants seeded by recent migs (127/132/133). |
|
||||
| Rows with `concept_id` (linked to `deadline_concepts`) | 125 | **129** | +4 | 56% of the corpus is concept-linked, vs 49% in the design. |
|
||||
| `paliad.deadlines` rows | 1 | **5** | +4 | Still tiny — destructive cutover stays cheap. |
|
||||
| `paliad.submission_drafts` rows | 4 | **7** | +3 | |
|
||||
| Rules in `lifecycle_state = 'draft'` | 4 | **0** | -4 | All 4 design-era drafts were published or discarded. |
|
||||
|
||||
### event_type distribution
|
||||
|
||||
| `event_type` | Design | Live | Δ |
|
||||
|---|--:|--:|--:|
|
||||
| `filing` | 130 | 105 | -25 |
|
||||
| NULL | 77 | 89 | +12 |
|
||||
| `decision` | 25 | 21 | -4 |
|
||||
| `hearing` | 21 | 15 | -6 |
|
||||
| `order` | 1 | 1 | 0 |
|
||||
| **Total** | **254** | **231** | -23 |
|
||||
|
||||
The -23 row delta lands almost entirely in `filing` (-25) and `hearing` (-6), offset by +12 NULL — consistent with the disappearance of the `_archived_litigation.*` filings and a few archived `hearing` rows, plus seeding of new structural / parent-only rows by recent migrations.
|
||||
|
||||
### What did NOT drift (qualitative claims, still valid)
|
||||
|
||||
- `paliad.deadline_rules` carries 39 columns (design said 38 — drift +1; likely from mig 128 `deadline_rules_unit_check` which adds a CHECK without adding a column — or one of migs 124-133 added a column. Not investigated further; out of B.0 scope).
|
||||
- `paliad.deadlines.rule_id` (uuid, nullable) is the FK column to `paliad.deadline_rules.id`. **Confirmed via `information_schema.referential_constraints`** — `rule_id → paliad.deadline_rules(id)`. The doc-side mention of `deadline_rule_id` was always a typo.
|
||||
- `paliad.deadlines.rule_code` + `paliad.deadlines.custom_rule_text` both still present (the denormalized-display columns from mig 122).
|
||||
- `paliad.submission_drafts` uses `(project_id uuid nullable, submission_code text NOT NULL)` as its key — **no FK to deadline_rules**. Confirms the design's claim that the Schriftsätze surface filters on a text key, not on `deadline_rules.id`.
|
||||
- No `paliad.proceeding_event*` tables exist (einstein's 2026-05-08 graph design was never built — still the case).
|
||||
|
||||
---
|
||||
|
||||
## §2 Archived submission_code audit (Q5 re-confirm)
|
||||
|
||||
**Premise re-checked:** "10 archived multi-row submission_codes (`_archived_litigation.*`) collapse safely into single procedural events with multiple sequencing variants."
|
||||
|
||||
**Finding:** the premise is **moot in the live DB**.
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%'
|
||||
GROUP BY submission_code;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT submission_code, COUNT(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code IS NOT NULL
|
||||
GROUP BY submission_code
|
||||
HAVING COUNT(*) > 1;
|
||||
-- 0 rows
|
||||
```
|
||||
|
||||
Every active submission_code in the live corpus is 1:1 with its `deadline_rules` row. The 10 multi-row codes the design anticipated no longer exist.
|
||||
|
||||
**Consequence for B.1 backfill:**
|
||||
|
||||
- The §5.1 / §5.2 backfill SQL the design sketched (collapsing N rows-with-same-submission_code into 1 procedural_event + N sequencing_rules) is **simpler than expected**: a straight 1:1 backfill, no GROUP-BY-and-collapse step needed.
|
||||
- B.1's `INSERT INTO paliad.procedural_events ... SELECT DISTINCT submission_code ...` becomes equivalent to `INSERT ... SELECT submission_code, ... FROM deadline_rules WHERE submission_code IS NOT NULL`. No deduplication needed.
|
||||
- The 78 rows where `submission_code IS NULL` (231 - 153) still need a B.1 decision: do they become `procedural_events` rows (with synthetic codes), do they become free-standing `sequencing_rules` with `procedural_event_id` NULL, or do they get parked? This was implicit in the design (the 77 NULLs were framed as "structural / parent-only rows in the proceeding tree"); B.1 should make the decision explicit and document it in the migration's `.up.sql` comments.
|
||||
|
||||
---
|
||||
|
||||
## §3 concept_id attachment shape (Q6 re-confirm)
|
||||
|
||||
**Premise re-checked:** "concept_id attaches to procedural event, not sequencing rule."
|
||||
|
||||
**Finding:** **partly true.** The FK direction the design proposes (`procedural_events.concept_id → deadline_concepts.id`, N:1) is correct. The verbal phrasing in Q6's default needs refinement — the empirical attachment is **above** the procedural-event level, not "at" it.
|
||||
|
||||
### Empirical pattern
|
||||
|
||||
129 of 231 rows carry a `concept_id`. Those 129 rows reference **53 distinct `deadline_concepts`** rows. Averages: 2.43 rows-per-concept, 2.42 submission-codes-per-concept (the two are nearly identical because today's corpus has no multi-row submission codes — see §2). Span distribution:
|
||||
|
||||
- 33 of 53 concepts (62%) attach to exactly 1 submission_code → procedural-event-scoped.
|
||||
- 20 of 53 concepts (38%) attach to >1 submission_code → cross-procedural-event scoped.
|
||||
- Maximum: 1 concept attaches to **15 distinct submission_codes**.
|
||||
|
||||
### Example: one concept, four procedural events
|
||||
|
||||
The concept `b85b2e5a-4064-40b2-b862-24b7abaa5b94` ("Berufungsfrist / Berufungsschrift") is referenced by 4 `deadline_rules` rows that today carry these 4 distinct submission_codes:
|
||||
|
||||
| rule_code | submission_code | court | name |
|
||||
|---|---|---|---|
|
||||
| § 110 PatG | `de.null.bgh.berufung` | BGH | Berufungsschrift |
|
||||
| § 110 PatG | `de.null.bpatg.berufung` | BPatG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.lg.berufung` | LG | Berufungsfrist |
|
||||
| § 517 ZPO | `de.inf.olg.berufung` | OLG | Berufungsfrist |
|
||||
|
||||
Under Slice B's target schema (§4.1), each of these four rows becomes a separate `procedural_events` row (different `code`s, different jurisdiction-specific names, different `legal_source_id`s), but **all four reference the same `deadline_concepts.id`**.
|
||||
|
||||
### Implication for B.1
|
||||
|
||||
- `procedural_events.concept_id` should be **nullable** (62% of rows today have no concept link — the §4.1 sketch already allows this).
|
||||
- The constraint must be **N:1, not 1:1** (one `deadline_concept` may be referenced by many `procedural_events`). The §4.1 sketch (`concept_id uuid REFERENCES paliad.deadline_concepts(id)`) is already correctly N:1; a hypothetical "UNIQUE INDEX on `procedural_events.concept_id`" would break the existing data. **Do not add UNIQUE.**
|
||||
- The design doc's Q6 phrasing can be tightened to: "concept_id attaches to procedural event (N procedural events → 1 concept). Sequencing rules do not carry concept_id." — but this is a wording nit, not a structural change. It does **not** block B.1.
|
||||
|
||||
---
|
||||
|
||||
## §4 Snapshot precedent audit
|
||||
|
||||
**Premise re-checked:** the `paliad.deadline_rules_pre_<N>` snapshot pattern is established and ready for B.4's destructive drop.
|
||||
|
||||
**Finding:** confirmed and consistent.
|
||||
|
||||
Snapshot tables in `paliad`:
|
||||
|
||||
| Snapshot table | Origin migration |
|
||||
|---|---|
|
||||
| `deadlines_pre_089` | mig 089 |
|
||||
| `deadline_rules_pre_091` | mig 091 (destructive drop of legacy columns) |
|
||||
| `event_deadlines_pre_092` | mig 092 |
|
||||
| `event_deadline_rule_codes_pre_092` | mig 092 |
|
||||
| `deadline_rules_pre_093` | mig 093 |
|
||||
| `proceeding_types_pre_093` | mig 093 |
|
||||
| `projects_pre_094` | mig 094 |
|
||||
| `deadline_rules_pre_095` | mig 095 |
|
||||
| `proceeding_types_pre_096` | mig 096 |
|
||||
| `deadline_rules_pre_098` | mig 098 |
|
||||
|
||||
Pattern: `<original_table>_pre_<migration_number>`. Always created in the `.up.sql` of the destructive migration as `CREATE TABLE paliad.<t>_pre_<N> AS TABLE paliad.<t>;` (followed by the destructive DROP / ALTER).
|
||||
|
||||
**B.4's template:** before `DROP TABLE paliad.deadline_rules;` (and `ALTER TABLE paliad.deadlines DROP COLUMN rule_id;`), `mig <N>.up.sql` must include:
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;
|
||||
-- (optional) CREATE TABLE paliad.deadlines_pre_<N> AS TABLE paliad.deadlines;
|
||||
```
|
||||
|
||||
This is non-negotiable per m's snapshot policy and the precedent of migs 089-098. B.4 should not enter the deploy queue without it.
|
||||
|
||||
---
|
||||
|
||||
## §5 deadlines.rule_id doc bug — verified + patched
|
||||
|
||||
**Premise re-checked:** the live column on `paliad.deadlines` referencing `deadline_rules` is named `rule_id`, not `deadline_rule_id`.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```sql
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema='paliad' AND table_name='deadlines' AND column_name LIKE '%rule%';
|
||||
-- rule_id (uuid, nullable)
|
||||
-- rule_code (text, nullable)
|
||||
-- custom_rule_text (text, nullable)
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT kcu.column_name, ccu.table_name, ccu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu ON ...
|
||||
JOIN information_schema.constraint_column_usage ccu ON ...
|
||||
WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema='paliad' AND tc.table_name='deadlines';
|
||||
-- rule_id → paliad.deadline_rules.id
|
||||
```
|
||||
|
||||
**Fix applied on this branch:**
|
||||
|
||||
- `docs/design-procedural-events-model-2026-05-25.md` — §1 row 51 already says "the column is `rule_id` (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo)". §1 row 63 (the "Doc-side bug flagged" line) already names the fix target. **No change needed to the design doc — the inventor already flagged and described the bug; B.0 just re-confirms it.**
|
||||
- `m/paliad#93` issue body — line 56 says `paliad.deadlines.deadline_rule_id` in the Q3 migration shape. Patched via Gitea API on this slice. See §6 of this report.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration tracker drift (out-of-scope context)
|
||||
|
||||
The design doc said "next available mig number is 124 (mig 123 = Backup Mode Slice A, just shipped)". Live state on 2026-05-26 13:30:
|
||||
|
||||
- Latest applied migration: **133** (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27).
|
||||
- Next available: **134**.
|
||||
- Migrations 124-133 (all applied after the design was authored):
|
||||
|
||||
```
|
||||
124 de_inf_lg_replik_duplik_sequencing (2026-05-25 13:49)
|
||||
125 cross_cutting_filter_legal_source (2026-05-25 14:13)
|
||||
126 users_inbox_seen_at (2026-05-25 13:51)
|
||||
127 wave0_tier0_deadline_fixes (2026-05-25 14:13)
|
||||
128 deadline_rules_unit_check (2026-05-25 14:13)
|
||||
129 project_event_choices (2026-05-25 15:02)
|
||||
130 submission_drafts_language (2026-05-25 15:05)
|
||||
131 submission_drafts_party_selection (2026-05-25 15:02)
|
||||
132 wave1_tier1_rule_additions (2026-05-25 15:40)
|
||||
133 upc_dmgs_pi_court_followup (2026-05-25 15:27)
|
||||
```
|
||||
|
||||
These touched `deadline_rules` content (wave0/wave1 rule additions, sequencing fixes, unit checks) and adjacent tables, but did not change the conflated-three-concepts shape that motivates Slice B. The structural premise of the design holds; the row-level numbers shifted.
|
||||
|
||||
**Side observation (not a B.0 fix scope):** the project's `CLAUDE.md` says "Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`)." That sentence is stale. The **canonical tracker is `paliad.applied_migrations`** (per `internal/db/migrate.go:9-21,53,105`). `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 counter, frozen at v106; the migrate runner uses it only to bootstrap `applied_migrations` on first deploy of the new runner (`internal/db/migrate.go:219-240`). Recommend a separate doc-fix slice (out of B.0 scope) to update `.claude/CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## §7 Updated B.1 brief (no-op / minor adjustments only)
|
||||
|
||||
What the live data means for the design's §5 migration plan:
|
||||
|
||||
1. **Backfill is simpler.** No multi-row collapse logic needed (§2). One-to-one `INSERT INTO paliad.procedural_events SELECT submission_code, name, name_en, description, event_type AS event_kind, primary_party, ... FROM paliad.deadline_rules WHERE submission_code IS NOT NULL` against 153 rows.
|
||||
2. **The 78 NULL-submission_code rows need an explicit decision in B.1.** Either:
|
||||
- (a) Skip them — they remain `deadline_rules`-only and become orphan-once-deadline_rules-is-dropped. Not acceptable; B.4 would lose them.
|
||||
- (b) Mint synthetic codes (`null.<uuid8>` or similar) for the structural rows and create `procedural_events` for them.
|
||||
- (c) Treat them as "sequencing-rule-only" (a `sequencing_rules` row with NULL `procedural_event_id`) — would require `sequencing_rules.procedural_event_id` to be nullable, which contradicts §4.1's NOT NULL FK.
|
||||
- Default recommendation: **(b)** — mint codes, preserve every row. B.1 must document the mint rule in the `.up.sql`. Surface this to head before scheduling B.1.
|
||||
3. **concept_id stays N:1 on procedural_events.** No UNIQUE constraint. §4.1's sketch already does this; just don't accidentally tighten it.
|
||||
4. **Use migration number 134** (or whatever's the live `MAX(version)+1` at B.1-write-time; re-check at the moment of writing the file).
|
||||
5. **Snapshot before drop in B.4:** `CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;` per §4 precedent. **This is the hard-stop pre-condition for B.4 entering the deploy queue.**
|
||||
6. **Submission_drafts.submission_code → procedural_events.code text join** continues to work unchanged through B.1-B.3 because both names match. No B.5 dual-write needed for `submission_drafts`. (The design's §6.3 already noted this.)
|
||||
|
||||
None of these change the **shape** of the design — they tighten the backfill SQL and surface one explicit decision (point 2) for head.
|
||||
|
||||
---
|
||||
|
||||
## §8 Outputs of this slice (B.0)
|
||||
|
||||
| Artifact | Status |
|
||||
|---|---|
|
||||
| `docs/design-procedural-events-b0-findings-2026-05-26.md` (this file) | created on `mai/curie/researcher-slice-b-zero` |
|
||||
| `docs/design-procedural-events-model-2026-05-25.md` | cherry-picked from `mai/cronus/inventor-procedural` onto this branch (design doc was never merged to main; B.0 brings it onto a branch off main so the doc bug fix has somewhere to land) |
|
||||
| m/paliad#93 issue body — `deadline_rule_id` → `rule_id` correction | patched via Gitea API |
|
||||
| Gitea comment on m/paliad#93 summarizing this report | posted (see §6 trailing summary on the issue) |
|
||||
|
||||
**Nothing migrated, nothing written to `paliad.deadline_rules` or any other live data table.** Only `mai.reports` (progress) and the GitHub issue body / repo files were touched.
|
||||
|
||||
---
|
||||
|
||||
## §9 Hard-stop status
|
||||
|
||||
**B.0 COMPLETE. AWAITING B.1 GREENLIGHT.**
|
||||
|
||||
Per the original instruction:
|
||||
|
||||
- B.1 (additive migration creating `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources` + backfill) requires explicit m approval before any new tables get created.
|
||||
- B.4 (destructive drop of `paliad.deadline_rules` + `paliad.deadlines.rule_id`) requires m's downtime-window approval AND a `paliad.deadline_rules_pre_<N>` snapshot table in the same migration.
|
||||
- This researcher (curie) stays parked until head re-hires.
|
||||
|
||||
---
|
||||
|
||||
## §10 Decisions worth surfacing to m before B.1 starts
|
||||
|
||||
1. **NULL-submission_code rows (78 of them) — what to do during backfill?** Recommendation (b): mint synthetic codes. m should confirm or pick (a)/(c).
|
||||
2. **B.5 deprecation header window length** — the design (§8.2) says "one slice". For 7 active submission_drafts that's safe; the question is whether external integrations (Word templates with `{{rule.X}}`) need a longer window. The variable-bag alias contract (`submission_vars.go`) covers Word templates without a wire-format change, so "one slice" is defensible. m should confirm.
|
||||
3. **Migration number reservation** — by the time B.1 ships, the live head may be 135+. The B.1 coder must re-check `MAX(version)` at write-time. (Not a decision; just a process note.)
|
||||
|
||||
These are the only open questions the B.0 audit surfaced. Everything else in the design holds.
|
||||
571
docs/design-procedural-events-model-2026-05-25.md
Normal file
571
docs/design-procedural-events-model-2026-05-25.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# Design — Procedural-Events Data Model (t-paliad-262)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-25
|
||||
**Issue:** m/paliad#93 (mai task t-paliad-262)
|
||||
**Branch:** `mai/cronus/inventor-procedural`
|
||||
**Status:** DESIGN — read-only, no schema or code changes in this branch.
|
||||
**B.0 re-validation:** see `docs/design-procedural-events-b0-findings-2026-05-26.md` (curie, 2026-05-26) for the live-DB premise re-check. Numeric §1 claims drifted; Q5 multi-row collapse premise is moot (no `_archived_litigation.*` rows remain); Q6 N:1 attachment confirmed; mig number target updated 124 → 134.
|
||||
**Prior art read:**
|
||||
- `docs/design-deadline-data-model-2026-05-08.md` (einstein, t-paliad-158) — proposed `proceeding_event_types` + `proceeding_event_edges`; the **graph-shape recommendation has not been built** (no `proceeding_event*` tables exist in the live DB as of 2026-05-25, verified via `information_schema.tables`).
|
||||
- `docs/design-fristen-phase2-2026-05-15.md` (Phase 2/3 unified-rule columns — migs 078/079/091, **shipped**).
|
||||
- `docs/design-submission-generator-2026-05-19.md` and `docs/design-submission-page-2026-05-22.md` (Slice 1 → Slice A of the Schriftsätze stack — shipped on top of today's `deadline_rules`).
|
||||
|
||||
This doc names a single conflation in the schema and proposes a two-slice fix (cosmetic immediate, structural follow-up). It is intentionally narrower than einstein's 2026-05-08 graph proposal — it does **not** re-litigate the proceeding-as-DAG question.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
`paliad.deadline_rules` today is **one row that wears three hats**:
|
||||
|
||||
1. **The procedural-event template** — `submission_code`, `name`, `name_en`, `description`, `event_type`, `primary_party`. This is "what kind of step is this in the proceeding": Rechtsbeschwerdebegründung, mündliche Verhandlung, Entscheidung, etc.
|
||||
2. **The legal-norm citation** — `legal_source`, `rule_code`, `alt_rule_code`, `rule_codes[]`. This is "the source-of-law anchor": § 102 PatG, UPC RoP R.220(1).
|
||||
3. **The sequencing rule** — `parent_id`, `trigger_event_id`, `duration_value`, `duration_unit`, `timing`, `alt_duration_*`, `combine_op`, `condition_expr`, `is_spawn`, `spawn_*`, `sequence_order`, `is_court_set`, `priority`, `anchor_alt`, `proceeding_type_id`. This is "how and when does it fire relative to other events".
|
||||
|
||||
The conflation surfaces most painfully in the submission-draft editor's variable sidebar (m's report 2026-05-25 15:02), where the lawyer sees field labels like `{{rule.submission_code}}` for what is plainly a *procedural-event code*, `{{rule.event_type}}` for what is plainly the *procedural-event kind*, and `{{rule.legal_source_pretty}}` for what is plainly the *legal norm* — all under a `rule.*` namespace that reads as if the lawyer were filling in arithmetic.
|
||||
|
||||
**Recommendation = Q1 option (C):**
|
||||
|
||||
- **Slice A (immediate, this design's coder shift):** cosmetic rename — placeholders, i18n labels, Go struct-comment naming, admin-UI page titles all shift to `procedural_event.*` as the canonical name. **Database schema, table name, column names, FK directions, JSON envelope keys on the wire all stay exactly as they are.** Old `{{rule.*}}` placeholders remain emitted in the variable bag as legacy aliases so existing Word templates and saved drafts keep working.
|
||||
- **Slice B (planned follow-up, separate mai task, separate slice plan):** structural rework — extract `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources`, with a phased dual-write migration. **Not shipped here.** This doc defines the target shape (§4) and the migration shape (§5) so the eventual coder has a brief, not so the eventual coder is hired today.
|
||||
|
||||
**Umbrella term lock = Q2 option (R):** **"procedural event"** (DE: **"Verfahrensschritt"**) as the umbrella covering filings, hearings, decisions, orders. Justification in §2.
|
||||
|
||||
Both Slice A and the eventual Slice B preserve the Schriftsätze surface (t-paliad-238/242/243): the submissions list query changes its predicate from `dr.event_type = 'filing'` to `pe.event_kind IN ('filing', 'reply')` (Slice B only) — same rows, cleaner predicate.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-25)
|
||||
|
||||
Every load-bearing claim was checked against the running paliad codebase + youpc Supabase. Numbers and schema facts are point-in-time as of 2026-05-25 15:30.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| `paliad.deadline_rules` carries the 38 columns listed in §0's three-hats decomposition. | `information_schema.columns WHERE table_schema='paliad' AND table_name='deadline_rules'` — 38 rows; columns confirmed verbatim. |
|
||||
| Live row count = 254. | `SELECT COUNT(*) FROM paliad.deadline_rules` → 254. |
|
||||
| 177 rows carry a `submission_code` (procedural-event identity); 158 distinct values. | `COUNT(*) FILTER (WHERE submission_code IS NOT NULL)` → 177; `COUNT(DISTINCT submission_code)` → 158. |
|
||||
| 102 rows carry a `legal_source`; 70 distinct citations. | Same query, `legal_source` column. |
|
||||
| 125 rows are linked to a `deadline_concepts` row via `concept_id`. | `COUNT(*) FILTER (WHERE concept_id IS NOT NULL)` → 125 (49 % of the corpus). |
|
||||
| `event_type` distribution: 130 `filing` · 77 NULL · 25 `decision` · 21 `hearing` · 1 `order`. | `SELECT event_type, count(*) GROUP BY event_type` — confirmed; the 77 NULL rows are structural / parent-only rows in the proceeding tree. |
|
||||
| 10 `submission_code` values appear on more than one row (jurisdictional / bilateral variants). | All 10 today are `_archived_litigation.*` codes (claimant/defendant splits + multi-stage hearing rows). Live non-archived codes are 1:1 with rows in the current corpus. |
|
||||
| `paliad.deadlines` joins to `deadline_rules` via column `rule_id` (uuid, FK). The text `rule_code` and free-text `custom_rule_text` (mig 122, t-paliad-258) are denormalized for display when the rule row is deleted. | `internal/services/deadline_service.go:69-127`; live column list confirms `rule_id`, `rule_code`, `custom_rule_text` — there is **no** `deadline_rule_id` column on deadlines (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo; the column is `rule_id`). |
|
||||
| `paliad.submission_drafts` keys to a procedural event via `submission_code` text — **no FK** to `deadline_rules`. | `information_schema.columns` for `submission_drafts`: `submission_code text` plus `(project_id, submission_code)` as the joint identifier. Confirms the Schriftsätze surface filters on the *text key*, not on `deadline_rules.id`. |
|
||||
| The Schriftsätze list (t-paliad-238) filters `deadline_rules` by `event_type='filing'` and `submission_code IS NOT NULL`. | `internal/handlers/submissions.go:193-211` — verbatim. |
|
||||
| The variable bag emits exactly 8 `rule.*` placeholders. | `internal/services/submission_vars.go:349-364` — `rule.submission_code`, `rule.name`, `rule.name_de`, `rule.name_en`, `rule.legal_source`, `rule.legal_source_pretty`, `rule.primary_party`, `rule.event_type`. Frontend i18n labels at `frontend/src/client/submission-draft.ts:158-185`. |
|
||||
| Admin rule-edit form binds the same `rule.X` fields. | `frontend/src/admin-rules-edit.tsx:74-110` + `frontend/src/client/admin-rules-edit.ts:253-278` — same eight columns surfaced as form inputs. |
|
||||
| The Fristenrechner client surface refers to `calc.rule.nameDE` / `calc.rule.nameEN`. | `frontend/src/client/fristenrechner.ts:1592,1655`. |
|
||||
| einstein's 2026-05-08 `proceeding_event_types` + `proceeding_event_edges` are **not** in the DB. | `SELECT table_name FROM information_schema.tables WHERE table_schema='paliad' AND table_name LIKE '%proceeding_event%'` → 0 rows. The graph-shape proposal was never built. |
|
||||
| `paliad.deadline_concepts` (57 rows in the original einstein audit; live count not directly queried this shift) still exists and is referenced via `deadline_rules.concept_id`. | `information_schema.tables` confirms `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_types`, `trigger_events`, `event_categories` all still present — the deadline-knowledge graph from the einstein design lives on alongside the unified rule columns. |
|
||||
| Phase 2/3 columns (`priority`, `condition_expr`, `is_court_set`, `lifecycle_state`, `draft_of`, `published_at`, `rule_codes[]`) are live and load-bearing. | `internal/models/models.go:622-684` + mig 091. Slice B's structural rework must preserve every one of these on the new `sequencing_rules` table — they are not legacy. |
|
||||
| Live `paliad.deadlines` references to rules are sparse (1 row in prod). | `SELECT COUNT(*) FROM paliad.deadlines` → 1. The 4 `submission_drafts` rows reference a procedural event by `submission_code` text only. Tiny live FK surface → migrations can be aggressive without losing user data. |
|
||||
| Migration tracker is `paliad.paliad_schema_migrations`; next available number is 124 (mig 123 = Backup Mode Slice A, just shipped). | `internal/db/migrations/` directory listing; latest applied = 123. |
|
||||
|
||||
**Doc-side bug flagged for this issue's body:** the deliverable spec writes `paliad.deadlines.deadline_rule_id` in §3 (Q3 migration shape). The live column is `paliad.deadlines.rule_id`. Slice B's rename target is therefore `paliad.deadlines.procedural_event_id`, renamed directly from `paliad.deadlines.rule_id` — there is no intermediate `deadline_rule_id` step (no such column exists). Updating the issue body is m's call — flagged here so it doesn't propagate into a coder brief. *(B.0 update 2026-05-26: issue body patched. See `docs/design-procedural-events-b0-findings-2026-05-26.md` §5.)*
|
||||
|
||||
---
|
||||
|
||||
## §2 m's vocabulary call (Q2 — lock the umbrella term)
|
||||
|
||||
m proposed "procedural event" in the report. Options weighed:
|
||||
|
||||
| Option | Reads as | Collisions | Verdict |
|
||||
|---|---|---|---|
|
||||
| **"procedural event"** (DE: "Verfahrensschritt") | Umbrella that naturally covers filings, hearings, decisions, orders. Matches lawyer mental model: "the next thing that happens in the proceeding". | None — no `paliad.procedural_event*` table or column today (verified). | **(R) — adopt as canonical.** |
|
||||
| "submission" | Today the Schriftsätze surface uses this for *filings only* (`event_type='filing'`). Expanding the meaning would silently change Slice A's semantics for an existing UI. | Surface-level collision with the Schriftsätze nomenclature already in production. | Reject — would lose precision for an existing concept. |
|
||||
| "event" / "event_type" | Existing `deadline_rules.event_type` column. | **Hard collision** with `paliad.events` (audit feed, distinct table, distinct meaning). Renaming around it would be worse than the conflation we're trying to fix. | Reject. |
|
||||
| "Verfahrensschritt" only (no English) | Cleanest German but no English fallback. | Bilingual UI (DE primary, EN secondary per project CLAUDE.md) requires both. | Reject in isolation — but **adopt as the canonical German rendering** of "procedural event". |
|
||||
| "Verfahrensereignis" | Closer literal translation of "procedural event". | None. | Reject in favor of "Verfahrensschritt" — m's broader vocabulary uses "Schritt" (e.g. "Antragsschritt") more naturally than "Ereignis", which already maps to `paliad.events` in the audit-feed sense. |
|
||||
|
||||
**Lock:**
|
||||
|
||||
| Surface | Canonical |
|
||||
|---|---|
|
||||
| English | **procedural event** (lowercase except sentence-initial) |
|
||||
| German | **Verfahrensschritt** (m. — der Verfahrensschritt) |
|
||||
| Plural EN | procedural events |
|
||||
| Plural DE | Verfahrensschritte |
|
||||
| Code identifier (Go struct names, TS types) | `ProceduralEvent`, `ProceduralEventKind`, `ProceduralEventTemplate` |
|
||||
| Snake-case (DB columns, JSON keys, i18n keys, placeholders) | `procedural_event`, `procedural_event_kind`, `procedural_events` (table) |
|
||||
| Slice A: variable-bag placeholder namespace | `procedural_event.*` (with `rule.*` kept as legacy alias) |
|
||||
| Slice B: table name (if shipped) | `paliad.procedural_events` |
|
||||
|
||||
`event_type` (the column) becomes `event_kind` in Slice B — using "kind" rather than "type" to free up the word "type" for the proceeding-level taxonomy (`paliad.proceeding_types`, untouched) and to mirror the "event_type vs event_kind" disambiguation einstein hit in the 2026-05-08 doc. In Slice A the column stays `event_type` (no DB change).
|
||||
|
||||
**Q2 is locked by inventor recommendation.** It costs nothing structurally and clears noise across every downstream conversation. If m disagrees in the head round-trip, the only thing that flips is the term — Slice A's scope shape stays.
|
||||
|
||||
---
|
||||
|
||||
## §3 Scope decision (Q1 — A vs B vs C)
|
||||
|
||||
**Recommendation = (C) — cosmetic rename now, structural rework as a planned follow-up.**
|
||||
|
||||
### Why not (A) — cosmetic only and stop
|
||||
|
||||
(A) leaves the model wrong forever. The conflation isn't just a labelling annoyance — it makes future questions harder to answer cleanly:
|
||||
|
||||
- "How many distinct procedural events does paliad model?" Today: ambiguous (rows vs distinct `submission_code`s vs distinct `(submission_code, proceeding_type_id)` tuples).
|
||||
- "Where can we attach a per-procedural-event Word template that's independent of which proceeding it appears in?" Today: nowhere — the FK chain forces a per-row template registry, see `internal/handlers/files.go` template fallback.
|
||||
- "Show me every sequencing rule that triggers a given procedural event across all proceedings." Today: requires joining `deadline_rules` to itself on `submission_code` + `parent_id`, brittle.
|
||||
|
||||
If m signals (A) anyway — fine; the cosmetic-only slice is a strict subset of (C)'s Slice A and ships the same value (label clarity in the editor). But the recommendation is to write down the structural target now while the analysis is fresh.
|
||||
|
||||
### Why not (B) — restructure immediately
|
||||
|
||||
(B) means: one slice plan, one cutover. With:
|
||||
- 254 live rule rows,
|
||||
- 1 live `paliad.deadlines` row,
|
||||
- 4 live `submission_drafts` rows,
|
||||
- 12 Go services + 6 handlers touching `deadline_rules` + 8 placeholder strings on the wire + the admin rule-editor UI bound to the column shape,
|
||||
|
||||
…doing this in one cutover means a big-bang migration during a downtime window. m has granted exactly one such window in recent memory (2026-05-15 for mig 091's destructive drops), and that one was constrained to a 4-column drop. A four-table restructure has a meaningfully larger blast radius; it warrants its own task with its own slice plan and its own risk review.
|
||||
|
||||
### Why (C) — cosmetic-rename Slice A this design, structural Slice B as a separate task
|
||||
|
||||
Three properties of (C) make it the safe call:
|
||||
|
||||
1. **Slice A is reversible at any time** — every change is in i18n strings, Go struct comments, admin-UI page titles, and the variable-bag aliases. No DB migration. No drop. A revert is a `git revert` of the Slice A commit.
|
||||
2. **Slice B is fully designed but uncommitted** — §4 and §5 below define the target shape and migration plan, but the design doc itself ships in Slice A. m can read it, redirect it, or park it without pressure to ship it now.
|
||||
3. **The Schriftsätze surface doesn't care which slice we ship** — Slice A leaves it on `event_type='filing'`; Slice B flips it to `event_kind IN ('filing', 'reply')` over a dual-write window. Either way, the lawyer-facing behavior is unchanged.
|
||||
|
||||
### Slice A's deliverable boundary (what gets renamed, what stays)
|
||||
|
||||
**Renamed in Slice A:**
|
||||
|
||||
- **i18n keys** for the admin rule-editor field labels: `admin.rules.edit.field.submission_code` → `admin.rules.edit.field.procedural_event_code`, etc. (16 keys total — `name`, `name_en`, `description`, `submission_code`, `rule_code`, `legal_source`, `primary_party`, `event_type` × DE/EN — full list in §7.1.)
|
||||
- **Variable-bag placeholder labels** in `submission-draft.ts:158-185`: the *visible label* (`{ de: "Schriftsatz-Code", en: "Submission code" }`) is unchanged for filings (filings are still Schriftsätze on that surface), but the **namespace shown next to the placeholder string** changes: lawyer sees `{{procedural_event.code}}` in the placeholder column with the same Schriftsatz-Code label and same value. The old `{{rule.submission_code}}` stays in the catalog as an "(alt)" entry pointing at the same field.
|
||||
- **Variable-bag emission** (`internal/services/submission_vars.go:351-364`): the bag emits **both** key-names for every value, so any Word template / saved draft holding `{{rule.X}}` keeps working without a touch. New templates and the in-app catalog show the canonical `{{procedural_event.X}}` name.
|
||||
- **Admin page titles + section headings**: "Regel bearbeiten" → "Verfahrensschritt bearbeiten" (DE), "Edit rule" → "Edit procedural event" (EN). "Regeln verwalten" → "Verfahrensschritte verwalten" / "Procedural events". The URL path `/admin/rules` stays — URL renames have downstream cost (bookmarks, audit log entries) and would need their own redirect slice (out of scope here).
|
||||
- **Go struct comments + service docstrings + worker-facing log lines** that refer to "the rule" → "the procedural event" where the referent is the procedural-event aspect (not the sequencing-rule aspect). Function names, type names, table name stay (Slice B handles those).
|
||||
- **The "Submission Code / Einreichung-Kennung" label** itself stays (it's the lawyer's anchor — they recognize it). The framing around it changes: it now reads as "the code that identifies this *procedural event*", not "the code attached to this *rule*".
|
||||
|
||||
**Untouched in Slice A:**
|
||||
|
||||
- Database schema. Table name (`paliad.deadline_rules`). Column names. FK directions. Indexes. RLS policies. Triggers. Audit log column `rule_id`.
|
||||
- Go struct names: `DeadlineRule` stays. The renames here are *prose*, not *code*. Renaming `DeadlineRule` to `ProceduralEvent` couples Slice A to Slice B's table rename — keep them decoupled.
|
||||
- JSON envelope keys on the wire (`POST /api/admin/rules/:id` still accepts `submission_code` in the body — Slice B's API rename is a breaking change with its own deprecation window).
|
||||
- URL paths (`/admin/rules`, `/api/admin/rules/:id`, `/api/projects/:id/submissions` etc.).
|
||||
- `paliad.deadlines.rule_id` FK column name.
|
||||
- The variable-bag's legacy `{{rule.X}}` keys — kept forever as aliases (cheap, zero rot).
|
||||
- The `submission_drafts` table's `submission_code` text key.
|
||||
|
||||
This boundary makes Slice A a one-day coder shift: scoped, reversible, label-only.
|
||||
|
||||
### What Slice B inherits
|
||||
|
||||
Slice B inherits a codebase + a UI where every prose surface already speaks "procedural event". It also inherits a *legacy alias contract* (the dual emission in the variable bag) that gives it freedom to rename the JSON keys on the wire and the Go struct in two separate sub-slices without rushing.
|
||||
|
||||
---
|
||||
|
||||
## §4 Restructure schema (Q3 — if/when we ship Slice B)
|
||||
|
||||
This is the target the eventual Slice B coder would land. **Nothing here ships in this task.**
|
||||
|
||||
### §4.1 Three new tables (plus the rename of `deadline_rules`)
|
||||
|
||||
```sql
|
||||
-- 1. Procedural event templates — one row per (procedural-event identity)
|
||||
-- For now the live corpus is 1:1 with non-archived submission_codes
|
||||
-- (148 of the 158 distinct codes), so we get ~177 rows minus the 10
|
||||
-- multi-row codes' duplicates. Bilateral / jurisdictional variants
|
||||
-- are modeled at the sequencing_rules layer.
|
||||
CREATE TABLE paliad.procedural_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE, -- former submission_code
|
||||
name text NOT NULL, -- DE
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
event_kind text NOT NULL, -- filing|reply|hearing|decision|order|other
|
||||
primary_party_default text, -- claimant|defendant|both|court
|
||||
legal_source_id uuid REFERENCES paliad.legal_sources(id),
|
||||
concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
lifecycle_state text NOT NULL DEFAULT 'published', -- draft|published|archived
|
||||
draft_of uuid REFERENCES paliad.procedural_events(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 2. Legal sources — the source-of-law citations the procedural event
|
||||
-- anchors against. ~70 distinct values today (live corpus).
|
||||
CREATE TABLE paliad.legal_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation text NOT NULL UNIQUE, -- "DE.PatG.102", "UPC.RoP.220.1", …
|
||||
jurisdiction text NOT NULL, -- DE|UPC|EPA|DPMA|other
|
||||
pretty_de text NOT NULL, -- "§ 102 PatG"
|
||||
pretty_en text NOT NULL, -- "Section 102 PatG"
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 3. Sequencing rules — the timing / trigger / condition mechanics that
|
||||
-- today live alongside the procedural-event identity on deadline_rules.
|
||||
-- One row per (procedural_event × proceeding × variant). The 10
|
||||
-- "_archived_litigation.*" codes that today have 2-5 rows become
|
||||
-- 2-5 sequencing_rules rows for the same procedural_events row.
|
||||
CREATE TABLE paliad.sequencing_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
|
||||
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
parent_id uuid REFERENCES paliad.sequencing_rules(id), -- structural tree, today's parent_id
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id), -- event-rooted variant
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months',
|
||||
timing text DEFAULT 'after',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text,
|
||||
alt_rule_code text, -- legacy free-text alt citation, retained
|
||||
anchor_alt text,
|
||||
combine_op text, -- max|min
|
||||
condition_expr jsonb,
|
||||
primary_party text, -- per-rule override of the procedural_event default
|
||||
sequence_order integer NOT NULL DEFAULT 0,
|
||||
is_spawn boolean NOT NULL DEFAULT false,
|
||||
spawn_label text,
|
||||
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
|
||||
is_bilateral boolean NOT NULL DEFAULT false,
|
||||
is_court_set boolean NOT NULL DEFAULT false,
|
||||
priority text NOT NULL DEFAULT 'mandatory',
|
||||
rule_code text, -- legacy short-form citation, retained on the rule
|
||||
rule_codes text[], -- multi-citation array (mig pre-091)
|
||||
deadline_notes text,
|
||||
deadline_notes_en text,
|
||||
lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
draft_of uuid REFERENCES paliad.sequencing_rules(id),
|
||||
published_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 4. Rename downstream FK + add the link to procedural_events.
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
|
||||
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
|
||||
-- (rule_id stays as a transitional alias during the dual-write window;
|
||||
-- dropped at end of Slice B)
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 5. Submission drafts: add procedural_event_id FK alongside submission_code.
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id);
|
||||
-- (submission_code stays — it's the cosmetic anchor lawyers recognize
|
||||
-- in URLs and chat, and it doubles as the procedural_events.code value)
|
||||
```
|
||||
|
||||
### §4.2 What goes where (column-by-column map)
|
||||
|
||||
Every column on today's `paliad.deadline_rules` lands on exactly one of the three new tables:
|
||||
|
||||
| Today's `deadline_rules` column | Lands on | Notes |
|
||||
|---|---|---|
|
||||
| `id`, `created_at`, `updated_at` | `sequencing_rules` | The current row's identity becomes a sequencing-rule row. `procedural_events.id` is **new** — backfilled from `submission_code`. |
|
||||
| `submission_code` | `procedural_events.code` | Promoted up. Multi-row codes (10 in corpus, all `_archived_litigation.*`) collapse to one row on the new table; the 2-5 sequencing rows hang off it. |
|
||||
| `name`, `name_en`, `description` | `procedural_events` | Procedural-event identity. |
|
||||
| `primary_party` | `procedural_events.primary_party_default` AND `sequencing_rules.primary_party` | Both. The procedural event has a default party (claimant for Klage etc.); the sequencing rule can override per-jurisdiction (bilateral variants — e.g. `litigation.reply` claimant vs defendant become two sequencing rows with overridden party). |
|
||||
| `event_type` | `procedural_events.event_kind` | Hat 1, with rename to `event_kind` (term lock §2). |
|
||||
| `legal_source` | `legal_sources.citation` + FK from `procedural_events.legal_source_id` | The citation moves to its own row; the procedural event points at it. `pretty_de` / `pretty_en` materialize the existing `legalSourcePretty()` function output as columns (with the function retained as the migration source). |
|
||||
| `rule_code`, `alt_rule_code`, `rule_codes[]` | `sequencing_rules` | Short-form citation arrays stay on the sequencing rule — they're rule-specific. |
|
||||
| `proceeding_type_id`, `parent_id`, `trigger_event_id`, `spawn_proceeding_type_id`, `is_spawn`, `spawn_label`, `is_bilateral`, `is_court_set`, `combine_op` | `sequencing_rules` | Hat 3 (mechanics) — exact copies. |
|
||||
| `duration_value`, `duration_unit`, `timing`, `alt_duration_value`, `alt_duration_unit`, `anchor_alt` | `sequencing_rules` | Hat 3 (mechanics). |
|
||||
| `condition_expr` (jsonb) | `sequencing_rules` | Hat 3. The grammar from mig 091 stays. |
|
||||
| `priority`, `sequence_order` | `sequencing_rules` | Hat 3. |
|
||||
| `is_active`, `lifecycle_state`, `draft_of`, `published_at` | **BOTH** `procedural_events` AND `sequencing_rules` | A procedural event can be retired independently of any one of its sequencing variants. Backfill: copy onto both during dual-write; new rows go through the rule-editor service which writes both sides together. |
|
||||
| `concept_id` (FK to `deadline_concepts`) | `procedural_events.concept_id` | The concept layer (einstein 2026-05-08) attaches to the procedural event, not the sequencing rule. |
|
||||
| `deadline_notes`, `deadline_notes_en` | `sequencing_rules` | They're rule-specific notes ("filing the appeal in DE costs €X if you also did Y") — not procedural-event-wide. |
|
||||
|
||||
Three columns disappear:
|
||||
|
||||
- The semantically-overloaded part of `event_type` (renamed to `event_kind` and moved).
|
||||
- The "what is this thing" vs "how does it fire" name conflict — gone by construction.
|
||||
- Any column that exists only because of the conflation (none of today's columns are pure overhead — they all carry data — so the count stays at 38 across the three new tables).
|
||||
|
||||
### §4.3 Indexes + RLS
|
||||
|
||||
`paliad.can_see_project()` is the canonical RLS predicate (mig 055). None of the three new tables hold project-scoped data — they're firm-wide reference tables. RLS = none, same posture as today's `deadline_rules` (which is firm-wide and unrestricted at the row level; access control is via the `lifecycle_state='published'` filter in the read paths).
|
||||
|
||||
Indexes inherited from today:
|
||||
|
||||
- `paliad.legal_sources(citation)` — UNIQUE.
|
||||
- `paliad.procedural_events(code)` — UNIQUE.
|
||||
- `paliad.procedural_events(concept_id)` — for the deadline-concept join.
|
||||
- `paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state)` — primary read path for the calculator.
|
||||
- `paliad.sequencing_rules(parent_id)` — tree walk.
|
||||
- `paliad.sequencing_rules(trigger_event_id)` — event-rooted variant.
|
||||
|
||||
---
|
||||
|
||||
## §5 Migration plan (Slice B — when it ships, not in this task)
|
||||
|
||||
Phased dual-write, so the cutover is **never** a single instant where the wire format flips. m gets to roll back any one phase with a `git revert` + an `ALTER TABLE` if a phase misbehaves in prod.
|
||||
|
||||
### §5.1 Phase 1 — Additive (no down-time)
|
||||
|
||||
1. Create `procedural_events`, `sequencing_rules`, `legal_sources`.
|
||||
2. Backfill `legal_sources` from `DISTINCT legal_source` on `deadline_rules` (70 rows). Populate `pretty_de`/`pretty_en` by calling the existing `legalSourcePretty()` function in a one-shot SQL/Go shim during the migration. Verify `COUNT(DISTINCT legal_source FROM deadline_rules) = COUNT(*) FROM legal_sources`.
|
||||
3. Backfill `procedural_events` from `DISTINCT submission_code` on `deadline_rules WHERE submission_code IS NOT NULL`. Take `name`, `name_en`, `event_type → event_kind`, `primary_party`, `concept_id`, `description` from the lowest-`id` rule row for each code (tie-breaker: lowest `sequence_order`). Verify `COUNT(*) FROM procedural_events = COUNT(DISTINCT submission_code FROM deadline_rules WHERE submission_code IS NOT NULL)` (= 158).
|
||||
4. Backfill `sequencing_rules` 1:1 from `deadline_rules` (254 rows). FK `procedural_event_id` resolved by code lookup; sequencing-rule row inherits the `deadline_rules.id` (so existing `deadlines.rule_id` FKs continue to resolve via the new column for the dual-write window — see Phase 3).
|
||||
5. Add `paliad.deadlines.procedural_event_id` + `sequencing_rule_id` columns, backfill from `deadlines.rule_id` join.
|
||||
6. Add `paliad.submission_drafts.procedural_event_id`, backfill from `submission_code` join.
|
||||
|
||||
This phase ships behind a feature flag (or just behind unused code) — readers + writers stay on `deadline_rules`. No behavior change.
|
||||
|
||||
### §5.2 Phase 2 — Dual-write (no down-time)
|
||||
|
||||
7. Update `RuleEditorService` to write to both `deadline_rules` (legacy) and (`procedural_events`, `sequencing_rules`, `legal_sources`) on every Create/Update/Publish/Archive. Audit log writes one row per side.
|
||||
8. Update read paths to **read from the new tables**, falling back to `deadline_rules` if the new row is missing (defense-in-depth during backfill catch-up).
|
||||
9. Run for ≥ 1 week (m's call on length). Compare row counts and a hash digest of the union daily — if drift, surface.
|
||||
|
||||
### §5.3 Phase 3 — Cutover (no down-time, but reversible only via re-application of the dual-write)
|
||||
|
||||
10. Flip read paths to **only** the new tables (`SubmissionVarsService.loadPublishedRule`, `DeadlineRuleService.*`, `SubmissionService.list`, `ProjectionService`, `FristenrechnerCalc`, etc.).
|
||||
11. Stop writing to `deadline_rules`.
|
||||
12. `paliad.deadlines.rule_id` is kept as a no-op alias for one more week; new writes go to `procedural_event_id` + `sequencing_rule_id`.
|
||||
13. `submission_drafts.submission_code` is kept as the URL anchor; the FK `procedural_event_id` is the primary join key going forward.
|
||||
|
||||
### §5.4 Phase 4 — Drop legacy (downtime window, destructive)
|
||||
|
||||
14. `paliad.deadline_rules_pre_<slice-B-mig>` snapshot of the entire table.
|
||||
15. DROP TABLE paliad.deadline_rules (after CASCADE-safe FK rewires).
|
||||
16. DROP COLUMN paliad.deadlines.rule_id (keep `rule_code` + `custom_rule_text` as the human-readable denormalized columns — they're the safety net for orphaned deadlines per t-paliad-258).
|
||||
|
||||
m grants this destructive phase its own window (precedent: mig 091 on 2026-05-15). Until then, the legacy table sits dormant.
|
||||
|
||||
### §5.5 Migration tracker
|
||||
|
||||
- Slice B uses migration numbers 124 (Phase 1 — create tables + backfill) and onward — a 4-5 migration sequence, one per phase boundary, mirroring the Phase 2/3 slicing that shipped under t-paliad-195.
|
||||
- Each migration includes a `paliad.audit_reason = 'mig <n>: <slice-B-phase>'` set_config like mig 091 did, so the audit log captures the schema journey.
|
||||
|
||||
---
|
||||
|
||||
## §6 Service-layer impact
|
||||
|
||||
### §6.1 Slice A — prose-only changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/services/submission_vars.go` | `addRuleVars` → also emit `procedural_event.code`, `procedural_event.name`, `procedural_event.name_de`, `procedural_event.name_en`, `procedural_event.legal_source`, `procedural_event.legal_source_pretty`, `procedural_event.primary_party`, `procedural_event.event_kind` (8 new keys, 1:1 with the 8 existing `rule.*` keys, same values). Rename docstrings + the package-level placeholder map comment ("`rule.*`" → "`procedural_event.*` (with legacy alias `rule.*`)"). |
|
||||
| `internal/services/deadline_rule_service.go` | Top-of-file comment + struct comment renames only. Method names stay (`DeadlineRuleService`, `GetByID`, etc.). |
|
||||
| `internal/services/rule_editor_service.go` | Same. |
|
||||
| `internal/services/projection_service.go`, `deadline_service.go`, `fristenrechner.go`, `submission_draft_service.go`, `event_trigger_service.go`, `event_deadline_service.go`, `proceeding_mapping.go`, `export_service.go` | No code changes. Comments mentioning "the rule"/"rules" stay accurate as long as the file is about sequencing — only services that surface the **identity** aspect of the rule (`submission_vars.go`) need a prose pass. |
|
||||
| `internal/handlers/submissions.go` | No SQL change. Type+comment renames: the catalog response type stays `submissionListEntry` (it's still a Schriftsatz-level list); doc comments speak of "procedural events whose kind is filing" instead of "rules of type filing". |
|
||||
| `internal/handlers/admin_rules.go` | URL path stays. JSON envelope stays. Page-render comments + log-line text shift to "procedural event". |
|
||||
| `internal/handlers/submission_drafts.go`, `deadlines.go`, `fristenrechner.go` | No service-layer change. |
|
||||
|
||||
### §6.2 Slice B — structural
|
||||
|
||||
Mostly load-bearing; not enumerated here in detail (out of scope per (R)=C). The shape:
|
||||
|
||||
- `RuleEditorService` splits into `ProceduralEventService` + `SequencingRuleService` + `LegalSourceService`. The Save / Publish / Archive flow on the editor coordinates all three.
|
||||
- `DeadlineRuleService.GetByID` becomes `SequencingRuleService.GetByID`; the `submission_code` lookup moves to `ProceduralEventService.GetByCode`.
|
||||
- `SubmissionVarsService.loadPublishedRule` becomes `loadPublishedProceduralEvent` and returns a triple (`event`, `defaultSequencingRule`, `legalSource`); the variable-bag emission consumes all three.
|
||||
- `ProjectionService` and the Fristenrechner calculator read from `sequencing_rules` (same column set, same logic — only the table name changes).
|
||||
- `SubmissionService.list` (handlers/submissions.go) filters `procedural_events.event_kind IN ('filing', 'reply')`.
|
||||
- Backfill orphans + audit triggers (mig 079 / 089) are re-pointed at `sequencing_rules` + a new `procedural_events_audit`.
|
||||
|
||||
---
|
||||
|
||||
## §7 UI / i18n impact
|
||||
|
||||
### §7.1 i18n keys (Slice A)
|
||||
|
||||
Existing keys (DE + EN) at `frontend/src/client/i18n.ts` lines ~2834-2920 and ~5800-5890 — surface area is *labels*, not *placeholders-in-Word*:
|
||||
|
||||
| Old key | New key (Slice A) | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `admin.rules.list.title` | `admin.procedural_events.list.title` | "Verfahrensschritte verwalten — Paliad" | "Manage procedural events — Paliad" |
|
||||
| `admin.rules.list.heading` | `admin.procedural_events.list.heading` | "Verfahrensschritte verwalten" | "Manage procedural events" |
|
||||
| `admin.rules.list.subtitle` | `admin.procedural_events.list.subtitle` | "Verfahrensschritte anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived." | "Create, edit and publish procedural events. Lifecycle: draft → published → archived." |
|
||||
| `admin.rules.list.new` | `admin.procedural_events.list.new` | "+ Neuer Verfahrensschritt" | "+ New procedural event" |
|
||||
| `admin.rules.col.submission_code` | `admin.procedural_events.col.code` | "Code" (drop "/ Einreichung-Kennung" — the new heading already disambiguates) | "Code" |
|
||||
| `admin.rules.col.legal_citation` | `admin.procedural_events.col.legal_source` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `admin.rules.col.name` | `admin.procedural_events.col.name` | "Bezeichnung" | "Name" |
|
||||
| `admin.rules.col.proceeding` | `admin.procedural_events.col.proceeding` | "Verfahrenstyp" | "Proceeding" |
|
||||
| `admin.rules.col.priority` | `admin.procedural_events.col.priority` | "Priorität" | "Priority" |
|
||||
| `admin.rules.col.lifecycle` | `admin.procedural_events.col.lifecycle` | "Lifecycle" | "Lifecycle" |
|
||||
| `admin.rules.col.modified` | `admin.procedural_events.col.modified` | "Zuletzt geändert" | "Last modified" |
|
||||
| `admin.rules.edit.title` | `admin.procedural_events.edit.title` | "Verfahrensschritt bearbeiten — Paliad" | "Edit procedural event — Paliad" |
|
||||
| `admin.rules.edit.heading.loading` | `admin.procedural_events.edit.heading.loading` | "Verfahrensschritt laden…" | "Loading procedural event…" |
|
||||
| `admin.rules.edit.breadcrumb` | `admin.procedural_events.edit.breadcrumb` | "← Verfahrensschritte verwalten" | "← Manage procedural events" |
|
||||
| `admin.rules.edit.field.submission_code` | `admin.procedural_events.edit.field.code` | "Code (Schriftsatz-Code / Einreichung-Kennung)" — keep the parenthetical so lawyers familiar with the old label know what they're looking at. | "Code (submission / procedural-event identifier)" |
|
||||
| `admin.rules.edit.field.rule_code` | `admin.procedural_events.edit.field.short_citation` | "Rechtsgrundlage (Kurzform)" | "Legal source (short form)" |
|
||||
| `admin.rules.edit.field.legal_source` | `admin.procedural_events.edit.field.legal_source` | "Rechtsgrundlage (Langform)" | "Legal source (long form)" |
|
||||
| `admin.rules.edit.field.name` | `admin.procedural_events.edit.field.name` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `admin.rules.edit.field.name_en` | `admin.procedural_events.edit.field.name_en` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `admin.rules.edit.field.proceeding` | `admin.procedural_events.edit.field.proceeding` | "Verfahrenstyp" | "Proceeding type" |
|
||||
| `admin.rules.edit.field.trigger` | `admin.procedural_events.edit.field.trigger` | "Trigger-Ereignis" | "Trigger event" |
|
||||
| `admin.rules.edit.field.parent` | `admin.procedural_events.edit.field.parent` | "Übergeordneter Verfahrensschritt (UUID)" | "Parent procedural event (UUID)" |
|
||||
| `admin.rules.edit.field.concept` | `admin.procedural_events.edit.field.concept` | "Konzept (UUID)" | "Concept (UUID)" |
|
||||
| `admin.rules.edit.field.sequence_order` | `admin.procedural_events.edit.field.sequence_order` | "Reihenfolge" | "Order" |
|
||||
| `admin.rules.edit.field.duration_value` | `admin.procedural_events.edit.field.duration_value` | "Dauer" | "Duration" |
|
||||
| `admin.rules.edit.field.primary_party` | `admin.procedural_events.edit.field.primary_party` | "Partei (typisch)" | "Primary party" |
|
||||
| `admin.rules.edit.field.event_type` | `admin.procedural_events.edit.field.event_kind` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
| `admin.rules.edit.field.description` | `admin.procedural_events.edit.field.description` | "Beschreibung" | "Description" |
|
||||
|
||||
**Legacy keys retained as aliases** so any existing translation imports or external integrations keep working — old keys point at the same DE/EN values during a deprecation window of one full Slice B cycle.
|
||||
|
||||
### §7.2 Variable-bag placeholders (Slice A)
|
||||
|
||||
`frontend/src/client/submission-draft.ts:155-185` — the catalog of placeholders the lawyer sees in the sidebar:
|
||||
|
||||
| Old placeholder (kept as legacy alias) | New canonical placeholder | DE label | EN label |
|
||||
|---|---|---|---|
|
||||
| `{{rule.submission_code}}` | `{{procedural_event.code}}` | "Code (Verfahrensschritt)" | "Code (procedural event)" |
|
||||
| `{{rule.name}}` | `{{procedural_event.name}}` | "Bezeichnung" | "Name" |
|
||||
| `{{rule.name_de}}` | `{{procedural_event.name_de}}` | "Bezeichnung (DE)" | "Name (DE)" |
|
||||
| `{{rule.name_en}}` | `{{procedural_event.name_en}}` | "Bezeichnung (EN)" | "Name (EN)" |
|
||||
| `{{rule.legal_source}}` | `{{procedural_event.legal_source}}` | "Rechtsgrundlage (Code)" | "Legal source (code)" |
|
||||
| `{{rule.legal_source_pretty}}` | `{{procedural_event.legal_source_pretty}}` | "Rechtsgrundlage" | "Legal source" |
|
||||
| `{{rule.primary_party}}` | `{{procedural_event.primary_party}}` | "Partei (typisch)" | "Primary party" |
|
||||
| `{{rule.event_type}}` | `{{procedural_event.event_kind}}` | "Art des Verfahrensschritts" | "Procedural-event kind" |
|
||||
|
||||
The catalog renders the canonical name in the "copy-this-placeholder" button. The variable bag (`submission_vars.go`) emits both names with identical values, so any Word template the lawyer already has continues to work; new templates are encouraged to use the canonical name.
|
||||
|
||||
### §7.3 Admin rule-editor form (Slice A)
|
||||
|
||||
`frontend/src/admin-rules-edit.tsx:74-110` — i18n key rebinds + heading text update. The DOM `id` attributes (`f-submission-code`, `f-rule-code`, `f-legal-source`, …) stay — they're internal, the rename here is cosmetic, the form still POSTs the same JSON envelope (Slice A doesn't touch the API). The fieldset `legend` for the "Identität" section changes to "Verfahrensschritt-Identität" (DE) / "Procedural-event identity" (EN). The "Verfahren & Trigger" section heading stays — that section is about sequencing, and Slice A doesn't rename sequencing-level labels (those are Slice B).
|
||||
|
||||
### §7.4 Project-detail Schriftsätze tab + dashboard
|
||||
|
||||
`frontend/src/client/submissions.ts`, `submissions-index.ts`: no surface-level label change in Slice A. The Schriftsätze tab continues to show Schriftsätze (the lawyer's preferred term for *filings specifically*). The tab is a filtered view onto procedural events of kind `filing`/`reply` — that distinction surfaces only in admin contexts.
|
||||
|
||||
### §7.5 Help text + docs
|
||||
|
||||
A short addition to the in-app help: "What is a procedural event?" — one-paragraph definition explaining the umbrella term, with examples (Klage, Klageerwiderung, mündliche Verhandlung, Endurteil). Stored in `frontend/src/client/i18n.ts` under `help.procedural_events.intro`. Out of scope for the URL/router changes — added as static copy where it fits naturally.
|
||||
|
||||
---
|
||||
|
||||
## §8 Slice plan
|
||||
|
||||
### §8.1 Slice A (this design's downstream task)
|
||||
|
||||
**Scope:** prose-only rename per §3 ("renamed in Slice A" list).
|
||||
|
||||
**Mechanics:**
|
||||
|
||||
1. Add 8 new placeholder keys to the variable bag in `submission_vars.go` (1:1 with the existing 8 `rule.*` keys). Keep the legacy keys.
|
||||
2. Update `frontend/src/client/submission-draft.ts` placeholder catalog labels.
|
||||
3. Rebind admin i18n keys per §7.1 (with legacy keys retained).
|
||||
4. Update admin page titles + section headings.
|
||||
5. Update Go struct comments + service docstrings in `submission_vars.go`, `deadline_rule_service.go`, `rule_editor_service.go`, `submission_draft_service.go`, `submissions.go` handler. No code-flow change.
|
||||
6. Update `internal/handlers/submissions.go` doc comments.
|
||||
7. Add a short `docs/glossary.md` entry (or extend an existing one) for "procedural event" / "Verfahrensschritt" — single source of truth for the term.
|
||||
8. Tests: rename strings in existing test fixtures + add a regression test that the variable bag emits **both** the legacy `rule.X` and the canonical `procedural_event.X` keys with the same value. (Critical — without this test, a future commit could drop the legacy alias and silently break user templates.)
|
||||
9. Manual smoke: open the admin rule editor, confirm the new title appears. Open the submission-draft editor, confirm both `{{rule.X}}` and `{{procedural_event.X}}` placeholders are listed (with canonical first). Generate a `.docx` from a project using each placeholder name — both render identically.
|
||||
|
||||
**Risk:** very low. No DB change, no API change, fully reversible.
|
||||
|
||||
**No hours estimate per project CLAUDE.md.**
|
||||
|
||||
### §8.2 Slice B (separate mai task — designed here, hired later)
|
||||
|
||||
**Scope:** structural rework per §4 + §5.
|
||||
|
||||
**Mechanics:** Phase 1 → Phase 4 per §5.
|
||||
|
||||
**Prerequisite:** m greenlights via a new mai task with this doc + §11's open items addressed. **Not part of Slice A.**
|
||||
|
||||
**Sub-slices (suggested for Slice B's own task):**
|
||||
|
||||
- **B.0** — Re-validate this doc's premises against live DB (numbers shift over weeks).
|
||||
- **B.1** — Phase 1 additive migration + backfill (mig 124).
|
||||
- **B.2** — Phase 2 dual-write + read-fallback.
|
||||
- **B.3** — Phase 3 read cutover (no schema change).
|
||||
- **B.4** — Phase 4 destructive drop (downtime window).
|
||||
- **B.5** — Rename Go types `DeadlineRule` → `SequencingRule` + `ProceduralEvent`; rename JSON API envelope keys with a deprecation header. Independent of B.4.
|
||||
- **B.6** — Rename admin URL paths `/admin/rules` → `/admin/procedural-events` with redirects. Optional / low-priority.
|
||||
|
||||
### §8.3 Why splitting is the right call
|
||||
|
||||
The conflation is real, but the *fix* for the most-painful surface (the editor sidebar) is independent of the table restructure. Splitting lets m ship the fix this week, see whether the prose change alone resolves enough of the cognitive friction, and then decide whether the structural rework is still worth the migration cost. If after Slice A m says "this reads fine now, B isn't worth it", that's a legitimate outcome — Slice B is a *good* refactor, not an *urgent* one.
|
||||
|
||||
---
|
||||
|
||||
## §9 Risk assessment
|
||||
|
||||
### §9.1 Slice A risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Lawyer's existing Word template has `{{rule.submission_code}}` baked in; a future commit drops the legacy alias and breaks templates. | Low (Slice A keeps the alias) | High if it happens | Regression test (§8.1 step 8) asserts both keys emit. Add an audit-log line on every variable-bag call recording which keys were consumed by the merge engine — gives a 30-day window of evidence before we'd consider deprecating the legacy keys. |
|
||||
| i18n key rename misses a binding, leaving an English string visible to a DE user. | Medium | Low | The build pipeline (`bun test` / `bun build`) fails on missing i18n keys in `i18n-keys.ts`. Add the new keys to the type union; leave the old keys in the union with `@deprecated` JSDoc. |
|
||||
| Renamed admin page heading confuses returning admin users ("Where did 'Regeln verwalten' go?"). | Medium | Low | One-time changelog entry; the URL `/admin/rules` is unchanged so muscle memory still lands them on the page. Internal users only (whitelist-gated). |
|
||||
| Slice A reads as "we're done" and Slice B never ships. | Medium | Medium (the model stays wrong) | This doc files the Slice B design as a separate task entry **before** Slice A merges, so the to-do is visible. m's call whether to schedule it. |
|
||||
|
||||
### §9.2 Slice B risks (deferred; recorded for the future task)
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Backfill collapses too eagerly: 10 multi-row submission_codes today are `_archived_litigation.*` — confirm they should collapse into one procedural event with 2-5 sequencing variants, vs. each row becoming its own procedural event. | The `_archived_litigation.*` codes are archived per their prefix — collapse is safe. **Decision-flag for Slice B's own design pass.** |
|
||||
| `deadline_concepts` linkage (125 of 254 rules link to a concept) — does the concept attach to the procedural event or the sequencing rule? §4.2 says procedural event; verify this is right when re-validating premises in B.0. | Read-path audit: every consumer that joins `deadline_rules.concept_id` (rule_editor, projection, fristenrechner) operates on the rule-level today. Reconfirm none of them depend on per-jurisdiction concept-attachment. |
|
||||
| The dual-write window introduces drift if a write hits one side and fails on the other. | Atomicity via single transaction per write in `RuleEditorService`. Daily drift-check job (one SELECT pair, alert if mismatched). |
|
||||
| `paliad.deadlines.rule_id` (1 live row, but more in future) — backfilling `procedural_event_id` + `sequencing_rule_id` must not orphan the live row. | The 1 live row joins cleanly. Backfill in the same migration that adds the new columns. |
|
||||
| The submission-draft `submission_code` text key — what if two `procedural_events.code` values collide post-rename (e.g. a draft was saved against a code that we then archive)? | Slice B Phase 1 enforces `procedural_events.code UNIQUE`; the backfill verifies no collision on the existing 158 distinct values. Drafts with codes that no longer exist as published procedural events are handled by the existing `submission_drafts.submission_code` text fallback (no FK enforcement). |
|
||||
| Slice B's API-key rename (`submission_code` → `code` in JSON) breaks external integrations. | None exist today (paliad is internal-only); add a one-Slice deprecation header (`X-Deprecated-Field: submission_code`) before flipping. |
|
||||
| **Coordination risk with future fristen/calculator work.** The Fristenrechner calculator reads `deadline_rules` directly today. Slice B Phase 2's read-fallback handles this, but a parallel calculator feature in flight could land changes that need re-merging. | B.0's job: confirm no in-flight task touches `deadline_rules` table shape before scheduling. |
|
||||
|
||||
### §9.3 What rolls Slice A back
|
||||
|
||||
`git revert <slice-a-commit>` + reload. Zero data side-effects (no DB writes). 30 seconds.
|
||||
|
||||
### §9.4 What rolls Slice B back
|
||||
|
||||
Per phase — Phases 1-3 reversible via reverting code + `DROP TABLE`. Phase 4 reversible only by restoring `deadline_rules` from the `_pre_<n>` snapshot taken at the start of Phase 4. Same posture as mig 091 — m's call when to commit to this point.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- **Renaming `paliad.events`** (the audit feed). Distinct table, distinct concept. The umbrella-term lock (§2) deliberately uses "procedural event" not "event" to avoid colliding with it.
|
||||
- **Renaming `paliad.deadline_concepts`** to align with the procedural-event taxonomy. The concept layer is the cross-proceeding semantic bridge (einstein 2026-05-08 Q5); the relationship "procedural event has-a concept" already reads cleanly under the new term.
|
||||
- **Per-jurisdiction variations of the same procedural event** (issue body's explicit out-of-scope). The 10 multi-row codes in the corpus today stay multi-row.
|
||||
- **Multi-tenant / cross-firm sharing of procedural events** — paliad is single-tenant per deploy via `FIRM_NAME`; cross-firm is a separate design.
|
||||
- **einstein's `proceeding_event_edges` graph proposal.** That design proposed a graph of typed event-types connected by typed edges. This design's procedural-events / sequencing-rules split is **compatible** with that graph shape (the edges would attach to procedural-event-IDs rather than sequencing-rule-IDs), but the graph layer is a Slice C, not Slice B. Flagged for future continuity, not part of either slice here.
|
||||
- **Renaming Go type `DeadlineRule` to `SequencingRule` or `ProceduralEvent` in Slice A.** Slice A is prose; Slice B's B.5 sub-slice handles the type rename. Coupling them costs the reversibility property.
|
||||
- **API-envelope key renames** (`submission_code` → `code`, `event_type` → `event_kind` on the wire). Slice B only.
|
||||
- **URL path renames** (`/admin/rules` → `/admin/procedural-events`). Slice B.6, optional.
|
||||
- **Touching `paliad.trigger_events`** beyond keeping the FK path open (today `deadline_rules.trigger_event_id`; Slice B maps to `sequencing_rules.trigger_event_id`).
|
||||
- **Touching `paliad.event_categories` / Pathway-B navigation.** Independent layer.
|
||||
|
||||
---
|
||||
|
||||
## §11 Open questions for m (escalated via `mai instruct head` per project CLAUDE.md)
|
||||
|
||||
Per project CLAUDE.md "Head answers questions — NO AskUserQuestion" rule, these are surfaced to head, not picked-as-chip with the user.
|
||||
|
||||
| ID | Question | Inventor recommendation | Material to head? |
|
||||
|---|---|---|---|
|
||||
| **Q1** | Scope: cosmetic-only (A) · full restructure (B) · cosmetic now + B as planned follow-up (C). | **(R) = C** | Yes — material. Defines whether Slice B is hired today or filed as a future task. |
|
||||
| **Q2** | Umbrella term: "procedural event" (DE: Verfahrensschritt) · "submission" (filings only) · "Verfahrensereignis" · other. | **(R) = procedural event / Verfahrensschritt** | Yes — material. The term ripples through every label in §7. Inventor's pick is the canonical choice; head can override with a single message. |
|
||||
| **Q3** | Slice B migration shape: confirmed (§4 + §5) or rescope. | **(R) = §4 + §5 as written, decision deferred until Slice B is hired** | No — informational. Locked when Slice B's own design pass runs. |
|
||||
| **Q4** | Effect on Schriftsätze surface: filter `procedural_events.event_kind IN ('filing', 'reply')` is acceptable replacement for today's `event_type='filing'`. | **(R) = yes, semantically equivalent under Slice B; no behavior change to lawyer.** | No — informational. |
|
||||
| **Q5** | Are the 10 archived multi-row submission_codes (`_archived_litigation.*`) safe to collapse into single procedural events with multiple sequencing variants in Slice B? | **(R) = yes, prefix indicates archival; collapse-safe.** | No — informational, defers to Slice B. |
|
||||
| **Q6** | `concept_id` attaches to procedural event, not sequencing rule. Confirmable? | **(R) = yes, per §4.2 (one concept per identity, not per jurisdiction).** | No — informational, defers to Slice B. |
|
||||
| **Q7** | Keep the legacy `{{rule.X}}` placeholder aliases **forever**, or set a deprecation horizon (e.g. 1 year)? | **(R) = forever, with `@deprecated` annotation in the catalog. Removing them risks breaking lawyer-authored templates that paliad doesn't see.** | Yes — material to Slice A's contract (test in §8.1 step 8 asserts both keys emit). |
|
||||
| **Q8** | Document side: update m/paliad#93 issue body to fix the `deadlines.deadline_rule_id` → `deadlines.rule_id` typo (§1 last paragraph). | **(R) = yes, head's call when to edit.** | No — informational, doc hygiene. |
|
||||
| **Q9** | After Slice A ships, do we file Slice B as a new mai task **now** (so it's visible), or wait for m to ask? | **(R) = file now, status:planning, no owner. Visibility >> deferred surprise.** | Yes — material to "does the model stay wrong forever". |
|
||||
|
||||
Q1, Q2, Q7, Q9 are the four head needs to answer before the coder shift. Q3-Q6, Q8 defer cleanly.
|
||||
|
||||
---
|
||||
|
||||
## §12 Appendix — verbatim m quote
|
||||
|
||||
From m's report 2026-05-25 15:02 (paliad#93 body):
|
||||
|
||||
> This shows how our 'rule' table system may need a revision?! It feels like we are rule based not submission based. But here we have a specific submission that is connected to a rule (as in: legal norm). And of course also connected to other 'procedural events' (which is a good term for it all) by rules how they are sequenced. But it makes it sound weird in the fields...
|
||||
|
||||
The design above takes m's three-way split — *the procedural event* / *the legal norm* / *the rule by which they are sequenced* — at face value and turns it into a column-level map (§4.2), a slice plan (§8), and a deprecation contract (§9.1).
|
||||
|
||||
---
|
||||
|
||||
*End of design.*
|
||||
@@ -46,7 +46,6 @@ import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
|
||||
import { renderAdminRulesList } from "./src/admin-rules-list";
|
||||
import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderAdminBackups } from "./src/admin-backups";
|
||||
@@ -284,7 +283,6 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-list.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-rules-export.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
@@ -416,7 +414,6 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
await Bun.write(join(DIST, "admin-rules-list.html"), renderAdminRulesList());
|
||||
await Bun.write(join(DIST, "admin-rules-edit.html"), renderAdminRulesEdit());
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/export — Slice 11b (t-paliad-192). Surfaces the
|
||||
// GET /admin/api/rules/export-migrations endpoint as a SQL preview the
|
||||
// editor can copy or download. Optional ?since=<audit-id> query lets
|
||||
// the editor scope the export to a particular audit window — empty =
|
||||
// every un-exported audit row.
|
||||
export function renderAdminRulesExport(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.export.title">Regel-Migrations exportieren — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.export.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 data-i18n="admin.rules.export.heading">Regel-Migrations exportieren</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.export.subtitle">
|
||||
Generiert ein <code>*.up.sql</code>-Blob mit allen unsynchronisierten Audit-Veränderungen.
|
||||
Manuell in <code>internal/db/migrations/</code> einchecken.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-rules-export-controls">
|
||||
<div className="form-field">
|
||||
<label htmlFor="export-since" data-i18n="admin.rules.export.field.since">Startend ab Audit-ID (optional)</label>
|
||||
<input type="text" id="export-since" className="admin-rules-input" placeholder="UUID, leer = alle un-exportierten" />
|
||||
</div>
|
||||
<button type="button" id="export-run" className="btn-primary" data-i18n="admin.rules.export.run">
|
||||
Export generieren
|
||||
</button>
|
||||
<button type="button" id="export-download" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.download">
|
||||
Als Datei herunterladen
|
||||
</button>
|
||||
<button type="button" id="export-copy" className="btn-secondary" style="display:none" data-i18n="admin.rules.export.copy">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="export-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-rules-export-summary" id="export-summary" style="display:none">
|
||||
<span id="export-summary-count" />
|
||||
<span id="export-summary-latest" />
|
||||
</div>
|
||||
|
||||
<pre id="export-output" className="admin-rules-export-pre" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-rules-export.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -39,9 +39,6 @@ export function renderAdminRulesList(): string {
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-rules-header-actions">
|
||||
<a href="/admin/rules/export" className="btn-secondary" data-i18n="admin.rules.list.export">
|
||||
Migrations exportieren
|
||||
</a>
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
||||
+ Neue Regel
|
||||
</button>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-export.ts — /admin/rules/export. Calls
|
||||
// GET /admin/api/rules/export-migrations[?since=<uuid>] and renders the
|
||||
// SQL blob server-side. Download builds a Blob URL and triggers a
|
||||
// fake <a> click; copy uses navigator.clipboard.
|
||||
|
||||
interface ExportResult {
|
||||
migration_sql: string;
|
||||
count: number;
|
||||
latest_audit_id: string;
|
||||
}
|
||||
|
||||
let latest: ExportResult | null = null;
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("export-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 4000);
|
||||
}
|
||||
|
||||
async function runExport() {
|
||||
const since = (document.getElementById("export-since") as HTMLInputElement).value.trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (since) qs.set("since", since);
|
||||
const url = "/admin/api/rules/export-migrations" + (qs.toString() ? "?" + qs.toString() : "");
|
||||
const out = document.getElementById("export-output") as HTMLElement;
|
||||
const summary = document.getElementById("export-summary") as HTMLElement;
|
||||
const dl = document.getElementById("export-download") as HTMLElement;
|
||||
const cp = document.getElementById("export-copy") as HTMLElement;
|
||||
out.textContent = t("admin.rules.export.running") || "Lade...";
|
||||
summary.style.display = "none";
|
||||
dl.style.display = "none";
|
||||
cp.style.display = "none";
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || (t("admin.rules.export.error") || "Export fehlgeschlagen."), true);
|
||||
out.textContent = "";
|
||||
return;
|
||||
}
|
||||
latest = await resp.json() as ExportResult;
|
||||
out.textContent = latest.migration_sql;
|
||||
summary.style.display = "";
|
||||
const countEl = document.getElementById("export-summary-count") as HTMLElement;
|
||||
const latestEl = document.getElementById("export-summary-latest") as HTMLElement;
|
||||
countEl.textContent = (t("admin.rules.export.count") || "Audit-Zeilen: {n}").replace("{n}", String(latest.count));
|
||||
if (latest.latest_audit_id) {
|
||||
latestEl.textContent = (t("admin.rules.export.latest") || "Letzte Audit-ID: {id}").replace("{id}", latest.latest_audit_id);
|
||||
} else {
|
||||
latestEl.textContent = "";
|
||||
}
|
||||
if (latest.count > 0) {
|
||||
dl.style.display = "";
|
||||
cp.style.display = "";
|
||||
showFeedback((t("admin.rules.export.ok") || "{n} Audit-Zeilen exportiert.").replace("{n}", String(latest.count)), false);
|
||||
} else {
|
||||
showFeedback(t("admin.rules.export.no_pending") || "Keine offenen Audit-Zeilen zum Export.", false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
if (!latest) return;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const name = `rules-export-${ts}.up.sql`;
|
||||
const blob = new Blob([latest.migration_sql], { type: "application/sql" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!latest) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(latest.migration_sql);
|
||||
showFeedback(t("admin.rules.export.copied") || "In Zwischenablage kopiert.", false);
|
||||
} catch (e) {
|
||||
showFeedback(t("admin.rules.export.copy_failed") || "Kopieren fehlgeschlagen.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
(document.getElementById("export-run") as HTMLElement).addEventListener("click", runExport);
|
||||
(document.getElementById("export-download") as HTMLElement).addEventListener("click", downloadFile);
|
||||
(document.getElementById("export-copy") as HTMLElement).addEventListener("click", copyToClipboard);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
@@ -309,6 +309,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
|
||||
"choices.caret.title": "Optionen für dieses Ereignis",
|
||||
"choices.appellant.title": "Berufung durch …",
|
||||
@@ -2892,7 +2894,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// `admin.procedural_events.*` aliases live after the EN block — they
|
||||
// pin the contract for when .tsx files rebind in Slice B (B.5).
|
||||
"nav.admin.rules": "Verfahrensschritte verwalten",
|
||||
"nav.admin.rules_export": "Verfahrensschritt-Migrations",
|
||||
"admin.card.rules.title": "Verfahrensschritte verwalten",
|
||||
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||
|
||||
@@ -2900,7 +2901,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.rules.list.subtitle": "Verfahrensschritte (Schriftsätze, Anhörungen, Entscheidungen, …) anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.rules.list.export": "Migrations exportieren",
|
||||
"admin.rules.tab.rules": "Regeln",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Lade…",
|
||||
@@ -3062,23 +3062,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.modal.restore.title": "Wiederherstellen",
|
||||
"admin.rules.edit.modal.restore.body": "Regel wird wiederhergestellt (archived → published).",
|
||||
|
||||
"admin.rules.export.title": "Regel-Migrations exportieren — Paliad",
|
||||
"admin.rules.export.heading": "Regel-Migrations exportieren",
|
||||
"admin.rules.export.subtitle": "Generiert ein *.up.sql-Blob mit allen unsynchronisierten Audit-Veränderungen. Manuell in internal/db/migrations/ einchecken.",
|
||||
"admin.rules.export.breadcrumb": "← Regeln verwalten",
|
||||
"admin.rules.export.field.since": "Startend ab Audit-ID (optional)",
|
||||
"admin.rules.export.run": "Export generieren",
|
||||
"admin.rules.export.running": "Lade…",
|
||||
"admin.rules.export.download": "Als Datei herunterladen",
|
||||
"admin.rules.export.copy": "In Zwischenablage kopieren",
|
||||
"admin.rules.export.copied": "In Zwischenablage kopiert.",
|
||||
"admin.rules.export.copy_failed": "Kopieren fehlgeschlagen.",
|
||||
"admin.rules.export.count": "Audit-Zeilen: {n}",
|
||||
"admin.rules.export.latest": "Letzte Audit-ID: {id}",
|
||||
"admin.rules.export.ok": "{n} Audit-Zeilen exportiert.",
|
||||
"admin.rules.export.error": "Export fehlgeschlagen.",
|
||||
"admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). Symmetric past/future chip fan
|
||||
// around an ALLES centre. Used by the filter-bar 'time' axis from
|
||||
// Slice A onwards; future slices will migrate /agenda and
|
||||
@@ -3417,6 +3400,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
|
||||
"choices.caret.title": "Options for this event",
|
||||
"choices.appellant.title": "Appeal by …",
|
||||
@@ -5966,7 +5951,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
|
||||
"nav.admin.rules": "Manage procedural events",
|
||||
"nav.admin.rules_export": "Procedural-event migrations",
|
||||
"admin.card.rules.title": "Manage procedural events",
|
||||
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
|
||||
|
||||
@@ -5974,7 +5958,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.list.heading": "Manage procedural events",
|
||||
"admin.rules.list.subtitle": "Author, edit and publish procedural events (filings, hearings, decisions, …). Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ New procedural event",
|
||||
"admin.rules.list.export": "Export migrations",
|
||||
"admin.rules.tab.rules": "Rules",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Loading…",
|
||||
@@ -6136,23 +6119,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.modal.restore.title": "Restore",
|
||||
"admin.rules.edit.modal.restore.body": "Rule will be restored (archived → published).",
|
||||
|
||||
"admin.rules.export.title": "Export rule migrations — Paliad",
|
||||
"admin.rules.export.heading": "Export rule migrations",
|
||||
"admin.rules.export.subtitle": "Generates a *.up.sql blob with every un-exported audit change. Commit manually into internal/db/migrations/.",
|
||||
"admin.rules.export.breadcrumb": "← Manage Rules",
|
||||
"admin.rules.export.field.since": "Starting from audit id (optional)",
|
||||
"admin.rules.export.run": "Generate export",
|
||||
"admin.rules.export.running": "Loading…",
|
||||
"admin.rules.export.download": "Download as file",
|
||||
"admin.rules.export.copy": "Copy to clipboard",
|
||||
"admin.rules.export.copied": "Copied to clipboard.",
|
||||
"admin.rules.export.copy_failed": "Copy failed.",
|
||||
"admin.rules.export.count": "Audit rows: {n}",
|
||||
"admin.rules.export.latest": "Latest audit id: {id}",
|
||||
"admin.rules.export.ok": "{n} audit rows exported.",
|
||||
"admin.rules.export.error": "Export failed.",
|
||||
"admin.rules.export.no_pending": "No pending audit rows to export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). See DE block above for details.
|
||||
"date_range.button.label": "Time range",
|
||||
"date_range.button.label.custom_range": "From {from} to {to}",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
renderColumnsBody,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
@@ -392,4 +394,73 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// m's correction in m/paliad#127 (t-paliad-295) reverted half of #88's
|
||||
// header refresh: the user-perspective labels "Unsere Seite"/"Gegnerseite"
|
||||
// only make sense once the user has picked a side. While the side is
|
||||
// still "Nicht festgelegt" (side === null — the default after #120) the
|
||||
// header falls back to the semantic-neutral "Proaktiv"/"Reaktiv" labels.
|
||||
// Picking a side re-enables the #88 labels. The bucketing primitive
|
||||
// itself is unchanged — only the column-header text differs.
|
||||
describe("renderColumnsBody — side-aware column header labels (m/paliad#127)", () => {
|
||||
const dlFix = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party,
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const data: DeadlineResponse = {
|
||||
proceedingType: "upc.inf.cfi",
|
||||
proceedingName: "UPC Verletzungsverfahren",
|
||||
triggerDate: "2026-01-01",
|
||||
deadlines: [
|
||||
dlFix("claimant", "Klageschrift", "2026-01-01"),
|
||||
dlFix("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
],
|
||||
};
|
||||
|
||||
test("side=null renders Proaktiv/Gericht/Reaktiv headers", () => {
|
||||
const html = renderColumnsBody(data, { side: null });
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
expect(html).not.toContain(">Unsere Seite<");
|
||||
expect(html).not.toContain(">Gegnerseite<");
|
||||
});
|
||||
|
||||
test("side=null when opts omitted (default) still renders Proaktiv/Reaktiv", () => {
|
||||
const html = renderColumnsBody(data);
|
||||
expect(html).toContain(">Proaktiv<");
|
||||
expect(html).toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=claimant renders Unsere Seite/Gericht/Gegnerseite headers", () => {
|
||||
const html = renderColumnsBody(data, { side: "claimant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gericht<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
|
||||
test("side=defendant renders Unsere Seite/Gegnerseite headers (column swap is bucketing, not labels)", () => {
|
||||
// The user-perspective labels are picked once a side is set; the
|
||||
// bucketer still routes defendant filings into the `ours` column when
|
||||
// side=defendant, so the left column's header truthfully reads
|
||||
// "Unsere Seite" regardless of which underlying party occupies it.
|
||||
const html = renderColumnsBody(data, { side: "defendant" });
|
||||
expect(html).toContain(">Unsere Seite<");
|
||||
expect(html).toContain(">Gegnerseite<");
|
||||
expect(html).not.toContain(">Proaktiv<");
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -756,14 +756,29 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
// Column-header labels have two modes (m/paliad#127):
|
||||
// - side picked → "Unsere Seite" / "Gegnerseite" (the columns
|
||||
// truthfully describe whose filings sit there,
|
||||
// because the bucketer routed the user's side into
|
||||
// `ours`).
|
||||
// - side === null → "Proaktiv" / "Reaktiv" (semantic-neutral). The
|
||||
// user-perspective labels would lie here: we don't
|
||||
// know yet which party is "us", so calling the left
|
||||
// column "Unsere Seite" presumes a pick the user
|
||||
// hasn't made. The neutral Proaktiv/Reaktiv pair
|
||||
// keeps the spatial axis ("who initiates vs who
|
||||
// responds") legible while the hint chip on the
|
||||
// page nudges the user to pick a side.
|
||||
//
|
||||
// Note: the COLUMN PROJECTION does not change — the bucketing primitive
|
||||
// still routes claimant→left, defendant→right when side=null (legacy
|
||||
// claimant-on-the-left fallback). Only the HEADER label changes.
|
||||
const leftLabel = userSide === null ? t("deadlines.col.proactive") : t("deadlines.col.ours");
|
||||
const rightLabel = userSide === null ? t("deadlines.col.reactive") : t("deadlines.col.opponent");
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(leftLabel, "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
html += headerCell(rightLabel, "fr-col-opponent");
|
||||
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
|
||||
@@ -205,7 +205,6 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
|
||||
@@ -401,22 +401,6 @@ export type I18nKey =
|
||||
| "admin.rules.edit.title"
|
||||
| "admin.rules.empty"
|
||||
| "admin.rules.error.load"
|
||||
| "admin.rules.export.breadcrumb"
|
||||
| "admin.rules.export.copied"
|
||||
| "admin.rules.export.copy"
|
||||
| "admin.rules.export.copy_failed"
|
||||
| "admin.rules.export.count"
|
||||
| "admin.rules.export.download"
|
||||
| "admin.rules.export.error"
|
||||
| "admin.rules.export.field.since"
|
||||
| "admin.rules.export.heading"
|
||||
| "admin.rules.export.latest"
|
||||
| "admin.rules.export.no_pending"
|
||||
| "admin.rules.export.ok"
|
||||
| "admin.rules.export.run"
|
||||
| "admin.rules.export.running"
|
||||
| "admin.rules.export.subtitle"
|
||||
| "admin.rules.export.title"
|
||||
| "admin.rules.filter.lifecycle"
|
||||
| "admin.rules.filter.lifecycle.any"
|
||||
| "admin.rules.filter.proceeding"
|
||||
@@ -428,7 +412,6 @@ export type I18nKey =
|
||||
| "admin.rules.lifecycle.archived"
|
||||
| "admin.rules.lifecycle.draft"
|
||||
| "admin.rules.lifecycle.published"
|
||||
| "admin.rules.list.export"
|
||||
| "admin.rules.list.heading"
|
||||
| "admin.rules.list.new"
|
||||
| "admin.rules.list.subtitle"
|
||||
@@ -1233,6 +1216,8 @@ export type I18nKey =
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1992,7 +1977,6 @@ export type I18nKey =
|
||||
| "nav.admin.paliadin"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.rules"
|
||||
| "nav.admin.rules_export"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
| "nav.akten"
|
||||
|
||||
@@ -18185,42 +18185,6 @@ dialog.quick-add-sheet::backdrop {
|
||||
border-top: 1px solid var(--color-border, #d4d4d8);
|
||||
}
|
||||
|
||||
/* Export page */
|
||||
|
||||
.admin-rules-export-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-rules-export-controls .form-field {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.admin-rules-export-summary {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted, #71717a);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-rules-export-pre {
|
||||
background: var(--color-bg-subtle, #f4f4f5);
|
||||
border: 1px solid var(--color-border, #d4d4d8);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
max-height: 60vh;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Date-range picker (t-paliad-248) ------------------------------------
|
||||
Symmetric past/future chip fan around an ALLES centre, in a popover
|
||||
anchored under a closed-state trigger button. Reuses .agenda-chip /
|
||||
|
||||
@@ -299,21 +299,6 @@ func handleAdminPreviewRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/export-migrations?since=<audit_id>
|
||||
func handleAdminExportRuleMigrations(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "rule editor unavailable"})
|
||||
return
|
||||
}
|
||||
since := r.URL.Query().Get("since")
|
||||
out, err := dbSvc.ruleEditor.ExportMigrationsSince(r.Context(), since)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page handlers — serve the static SPA shells. Auth + admin gate live
|
||||
// at the route registration in handlers.go.
|
||||
@@ -327,10 +312,6 @@ func handleAdminRulesEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-edit.html")
|
||||
}
|
||||
|
||||
func handleAdminRulesExportPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-rules-export.html")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// helpers
|
||||
// =============================================================================
|
||||
|
||||
@@ -670,10 +670,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-191 Slice 11a — admin rule-editor API.
|
||||
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
|
||||
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
||||
protected.HandleFunc("GET /admin/rules/export", adminGate(users, gateOnboarded(handleAdminRulesExportPage)))
|
||||
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
|
||||
protected.HandleFunc("GET /admin/api/rules/export-migrations", adminGate(users, handleAdminExportRuleMigrations))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
|
||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
|
||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
|
||||
|
||||
@@ -4,63 +4,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
||||
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
|
||||
// from Postgres breaks the row scan with "unsupported Scan, storing
|
||||
// driver.Value type <nil> into type *json.RawMessage" — exactly the
|
||||
// error that hid every approval_request from the inbox when m's first
|
||||
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
|
||||
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
|
||||
// fixes the scan and preserves inline JSON output (no base64 cast).
|
||||
type NullableJSON []byte
|
||||
|
||||
func (n *NullableJSON) Scan(value any) error {
|
||||
if value == nil {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
*n = append((*n)[:0], v...)
|
||||
return nil
|
||||
case string:
|
||||
*n = []byte(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
|
||||
}
|
||||
|
||||
func (n NullableJSON) Value() (driver.Value, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
func (n NullableJSON) MarshalJSON() ([]byte, error) {
|
||||
if len(n) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
*n = append((*n)[:0], data...)
|
||||
return nil
|
||||
}
|
||||
// NullableJSON is a jsonb column that may be NULL. Canonical definition
|
||||
// (with sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler)
|
||||
// lives in pkg/litigationplanner — kept here as a type alias so every
|
||||
// existing models.NullableJSON reference continues to compile.
|
||||
type NullableJSON = litigationplanner.NullableJSON
|
||||
|
||||
// User extends auth.users with firm-specific profile fields. Created by the
|
||||
// Phase D onboarding flow; without a row here, the user can't see any Projects.
|
||||
@@ -584,112 +541,10 @@ type Party struct {
|
||||
}
|
||||
|
||||
// DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
||||
type DeadlineRule struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
|
||||
// this rule's concept (joined via paliad.deadline_concept_event_types
|
||||
// where is_default = true). Lets the deadline create form auto-populate
|
||||
// the Typ chip when the user picks this rule. Hydrated by the service
|
||||
// layer; not a column. NULL when the concept has no mapped event_type.
|
||||
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
|
||||
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
|
||||
// Slice 9 (t-paliad-195) dropped the legacy IsMandatory /
|
||||
// IsOptional / ConditionFlag / ConditionRuleID fields — they
|
||||
// were superseded by Priority / ConditionExpr / IsCourtSet and
|
||||
// the unified calculator no longer reads them.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
|
||||
// trigger_event_id) is set after Slice 3.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain. Slice
|
||||
// 7 backfills the 8 live is_spawn=true rows.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression replacing
|
||||
// ConditionFlag (design §2.4). Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional. NullableJSON so a NULL column scans
|
||||
// cleanly (the row mishap that hid approval rows from the inbox
|
||||
// must not recur on rule rows).
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum replacing
|
||||
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'. Backfilled in
|
||||
// Slice 2; legacy callers read IsMandatory + IsOptional until
|
||||
// Slice 4 cuts them over.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic
|
||||
// (primary_party='court' OR event_type IN ('hearing','decision',
|
||||
// 'order')). Backfilled in Slice 2; legacy callers read the
|
||||
// heuristic until Slice 4.
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow (design §4.2):
|
||||
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
|
||||
// visible) | 'archived' (historical, retained for audit). Every
|
||||
// pre-Slice-1 row defaults to 'published' via the migration.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows. NULL also on net-
|
||||
// new drafts that have no prior published peer.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
// NULL while draft, set on publish, retained through archive.
|
||||
// Distinct from UpdatedAt (moves on every edit).
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
|
||||
// ChoicesOffered declares which per-event-card choice-kinds this
|
||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||
// t-paliad-265). NULL = no caret affordance (default). See the
|
||||
// COMMENT on paliad.deadline_rules.choices_offered for the value
|
||||
// shape. The engine and the frontend both read this column.
|
||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||
}
|
||||
// Canonical definition lives in pkg/litigationplanner.Rule — kept here
|
||||
// as a type alias so every existing models.DeadlineRule reference (sqlx
|
||||
// scans, hydration, projection service) continues to compile.
|
||||
type DeadlineRule = litigationplanner.Rule
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
// append-only audit log for every change to paliad.deadline_rules.
|
||||
@@ -721,43 +576,19 @@ type DeadlineRuleAudit struct {
|
||||
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
||||
// management) or the lowercase dot-separated fristenrechner codes
|
||||
// (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md.
|
||||
type ProceedingType struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||
Category *string `db:"category" json:"category,omitempty"`
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
|
||||
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// NULL on most proceedings — they already carry a root rule.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
}
|
||||
// ProceedingType is one of the litigation conceptual codes (INF / REV /
|
||||
// CCR / APM / APP / AMD / ZPO_CIVIL) or the lowercase dot-separated
|
||||
// fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md. Canonical
|
||||
// definition lives in pkg/litigationplanner.ProceedingType — kept here
|
||||
// as a type alias so every existing models.ProceedingType reference
|
||||
// continues to compile.
|
||||
type ProceedingType = litigationplanner.ProceedingType
|
||||
|
||||
// TriggerEvent is a UPC procedural event that can start one or more deadlines
|
||||
// running. Powers the "Was kommt nach…" Fristenrechner mode (event-driven
|
||||
// lookup, mirrored from youpc data.events).
|
||||
type TriggerEvent struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
Description string `db:"description" json:"description"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||
// whose semantic anchor is an event rather than a parent rule.
|
||||
// Canonical definition lives in pkg/litigationplanner.TriggerEvent.
|
||||
type TriggerEvent = litigationplanner.TriggerEvent
|
||||
|
||||
// EventDeadline is a single deadline that flows from a TriggerEvent. Mirrors
|
||||
// youpc data.deadlines + the trigger half of data.deadline_events.
|
||||
|
||||
@@ -38,7 +38,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
|
||||
choices_offered`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
category, default_color, sort_order, is_active,
|
||||
trigger_event_label_de, trigger_event_label_en`
|
||||
|
||||
// List returns active rules, optionally filtered by proceeding type.
|
||||
// Each row has ConceptDefaultEventTypeID hydrated from
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// DeadlineSearchService backs the unified Fristenrechner search bar
|
||||
@@ -921,130 +923,15 @@ func roundScore(v float64) float64 {
|
||||
return float64(int(v*10000+0.5)) / 10000
|
||||
}
|
||||
|
||||
// FormatLegalSourceDisplay renders a structured legal_source code into
|
||||
// the form HLC users read in pleadings:
|
||||
//
|
||||
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
|
||||
// UPC.RoP.139 → "UPC RoP R.139"
|
||||
// DE.PatG.82.1 → "PatG §82(1)"
|
||||
// DE.ZPO.276.1 → "ZPO §276(1)"
|
||||
// EU.EPÜ.108 → "EPÜ Art.108"
|
||||
// EU.EPC-R.79.1 → "EPC R.79(1)"
|
||||
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
|
||||
//
|
||||
// Returns the empty string for an empty input. Unknown jurisdictions
|
||||
// fall through with the structured form preserved (caller decides
|
||||
// whether to display).
|
||||
// FormatLegalSourceDisplay + BuildLegalSourceURL are canonically
|
||||
// defined in pkg/litigationplanner — kept here as thin re-exports so
|
||||
// the existing in-package + handler call-sites compile unchanged.
|
||||
func FormatLegalSourceDisplay(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
// Malformed — return as-is so the caller still has something.
|
||||
return src
|
||||
}
|
||||
code := parts[1]
|
||||
rest := parts[2:]
|
||||
var prefix string
|
||||
switch code {
|
||||
case "RoP":
|
||||
prefix = "UPC RoP R."
|
||||
case "PatG":
|
||||
prefix = "PatG §"
|
||||
case "ZPO":
|
||||
prefix = "ZPO §"
|
||||
case "EPÜ":
|
||||
prefix = "EPÜ Art."
|
||||
case "EPC-R":
|
||||
prefix = "EPC R."
|
||||
case "RPBA":
|
||||
prefix = "RPBA Art."
|
||||
default:
|
||||
prefix = code + " "
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(prefix) + len(src))
|
||||
b.WriteString(prefix)
|
||||
b.WriteString(rest[0])
|
||||
for _, p := range rest[1:] {
|
||||
b.WriteByte('(')
|
||||
b.WriteString(p)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
return b.String()
|
||||
return lp.FormatLegalSourceDisplay(src)
|
||||
}
|
||||
|
||||
// BuildLegalSourceURL maps a structured legal_source code to a
|
||||
// youpc.org/laws permalink when the cited body is hosted there. Today
|
||||
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
|
||||
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
|
||||
// home yet, so the helper returns the empty string for those and the
|
||||
// caller renders the display string as plain text.
|
||||
//
|
||||
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
|
||||
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
|
||||
// the law-number position are dropped; youpc resolves the page at
|
||||
// <type>.<number> granularity. The law-number is zero-padded to 3
|
||||
// digits to match how youpc stores law_number (laws-data.json carries
|
||||
// "001" / "023" / "220" forms).
|
||||
//
|
||||
// URL shape uses the hash-fragment form that youpc itself emits from
|
||||
// its laws-page redirect (handlers/laws.go:215+229) — the canonical
|
||||
// in-app deep link target. The `/laws/:type/:number` pretty route also
|
||||
// resolves the same page but redirects to the hash form anyway.
|
||||
//
|
||||
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
|
||||
// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139
|
||||
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
|
||||
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
|
||||
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
|
||||
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
|
||||
func BuildLegalSourceURL(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
var lawType string
|
||||
switch parts[0] + "." + parts[1] {
|
||||
case "UPC.RoP":
|
||||
lawType = "UPCRoP"
|
||||
case "UPC.UPCA":
|
||||
lawType = "UPCA"
|
||||
case "UPC.UPCS":
|
||||
lawType = "UPCS"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
number := padLawNumber(parts[2])
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://youpc.org/laws#" + lawType + "." + number
|
||||
}
|
||||
|
||||
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
|
||||
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
|
||||
// 112a) pass through unchanged so the URL still resolves. Empty input
|
||||
// returns the empty string.
|
||||
func padLawNumber(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if len(s) >= 3 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", 3-len(s)) + s
|
||||
return lp.BuildLegalSourceURL(src)
|
||||
}
|
||||
|
||||
// RefreshSearchView re-populates the materialised view. Safe to call on
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
221
internal/services/fristenrechner_sort_test.go
Normal file
221
internal/services/fristenrechner_sort_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for the trigger-group duration sort introduced
|
||||
// by t-paliad-296 / m/paliad#128. No DB needed — feeds synthetic
|
||||
// UIDeadlines and a ruleByID map directly into the helper.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// makeRule is a tiny constructor for a synthetic rule with just the
|
||||
// fields the sort reads (parent_id, duration_value, duration_unit,
|
||||
// submission_code, trigger_event_id).
|
||||
func makeRule(t *testing.T, parent *uuid.UUID, code string, val int, unit string) (uuid.UUID, models.DeadlineRule) {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
codeCopy := code
|
||||
return id, models.DeadlineRule{
|
||||
ID: id,
|
||||
ParentID: parent,
|
||||
SubmissionCode: &codeCopy,
|
||||
DurationValue: val,
|
||||
DurationUnit: unit,
|
||||
}
|
||||
}
|
||||
|
||||
func makeDeadline(id uuid.UUID, code string) UIDeadline {
|
||||
return UIDeadline{
|
||||
RuleID: id.String(),
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision is the
|
||||
// canonical scenario from m's report — four post-decision optional
|
||||
// events anchored on the same decision must render with 1-month rules
|
||||
// before 2-month rules.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_PostDecision(t *testing.T) {
|
||||
decisionID := uuid.New()
|
||||
|
||||
// Catalog order matches mig 132 sequence_order: cons_orders(60),
|
||||
// cost_app(70), rectification(70), appeal_spawn(80).
|
||||
consOrdID, consOrdRule := makeRule(t, &decisionID, "upc.inf.cfi.cons_orders", 2, "months")
|
||||
costAppID, costAppRule := makeRule(t, &decisionID, "upc.inf.cfi.cost_app", 1, "months")
|
||||
rectID, rectRule := makeRule(t, &decisionID, "upc.inf.cfi.rectification", 1, "months")
|
||||
appealID, appealRule := makeRule(t, &decisionID, "upc.inf.cfi.appeal_spawn", 2, "months")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
consOrdID: consOrdRule,
|
||||
costAppID: costAppRule,
|
||||
rectID: rectRule,
|
||||
appealID: appealRule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(consOrdID, "upc.inf.cfi.cons_orders"),
|
||||
makeDeadline(costAppID, "upc.inf.cfi.cost_app"),
|
||||
makeDeadline(rectID, "upc.inf.cfi.rectification"),
|
||||
makeDeadline(appealID, "upc.inf.cfi.appeal_spawn"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// 1-month tier first (cost_app, rectification — alphabetical by
|
||||
// submission_code), then 2-month tier (appeal_spawn, cons_orders
|
||||
// — submission_code ASC tiebreak per spec).
|
||||
want := []string{
|
||||
"upc.inf.cfi.cost_app",
|
||||
"upc.inf.cfi.rectification",
|
||||
"upc.inf.cfi.appeal_spawn",
|
||||
"upc.inf.cfi.cons_orders",
|
||||
}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight asserts the
|
||||
// unit-weight ordering: days < weeks < months < years, with shorter
|
||||
// durations of the same unit winning their tier.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_UnitWeight(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
|
||||
d14ID, d14Rule := makeRule(t, &parentID, "x.14days", 14, "days")
|
||||
d2wID, d2wRule := makeRule(t, &parentID, "x.2weeks", 2, "weeks")
|
||||
d1mID, d1mRule := makeRule(t, &parentID, "x.1month", 1, "months")
|
||||
d6mID, d6mRule := makeRule(t, &parentID, "x.6months", 6, "months")
|
||||
d1yID, d1yRule := makeRule(t, &parentID, "x.1year", 1, "years")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
d14ID: d14Rule, d2wID: d2wRule, d1mID: d1mRule, d6mID: d6mRule, d1yID: d1yRule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(d6mID, "x.6months"),
|
||||
makeDeadline(d1yID, "x.1year"),
|
||||
makeDeadline(d2wID, "x.2weeks"),
|
||||
makeDeadline(d14ID, "x.14days"),
|
||||
makeDeadline(d1mID, "x.1month"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
want := []string{"x.14days", "x.2weeks", "x.1month", "x.6months", "x.1year"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder
|
||||
// guards the hard rule: rules with different parents must keep their
|
||||
// relative position. Sorting only ever permutes adjacent same-parent
|
||||
// rows.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_NoCrossGroupReorder(t *testing.T) {
|
||||
parentAID := uuid.New()
|
||||
parentBID := uuid.New()
|
||||
|
||||
a3mID, a3mRule := makeRule(t, &parentAID, "ga.3months", 3, "months")
|
||||
b1mID, b1mRule := makeRule(t, &parentBID, "gb.1month", 1, "months")
|
||||
a14dID, a14dRule := makeRule(t, &parentAID, "ga.14days", 14, "days")
|
||||
b2mID, b2mRule := makeRule(t, &parentBID, "gb.2months", 2, "months")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
a3mID: a3mRule, b1mID: b1mRule, a14dID: a14dRule, b2mID: b2mRule,
|
||||
}
|
||||
|
||||
// Interleaved groups: A, B, A, B. Each group has one rule between
|
||||
// each other group's rules — the consecutive-run walk should treat
|
||||
// each as its own one-element run and not reorder anything.
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(a3mID, "ga.3months"),
|
||||
makeDeadline(b1mID, "gb.1month"),
|
||||
makeDeadline(a14dID, "ga.14days"),
|
||||
makeDeadline(b2mID, "gb.2months"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
want := []string{"ga.3months", "gb.1month", "ga.14days", "gb.2months"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q (interleaved groups must not reorder across)", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast asserts
|
||||
// that court-set / conditional rows (no concrete date in the duration
|
||||
// ladder) sort LAST within their group, regardless of their stated
|
||||
// duration value.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_ConditionalLast(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
|
||||
dID, dRule := makeRule(t, &parentID, "x.duration", 2, "months")
|
||||
cID, cRule := makeRule(t, &parentID, "x.conditional", 1, "months")
|
||||
csID, csRule := makeRule(t, &parentID, "x.courtset", 1, "months")
|
||||
d2ID, d2Rule := makeRule(t, &parentID, "x.short", 14, "days")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
dID: dRule, cID: cRule, csID: csRule, d2ID: d2Rule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
{RuleID: cID.String(), Code: "x.conditional", IsConditional: true},
|
||||
{RuleID: dID.String(), Code: "x.duration"},
|
||||
{RuleID: csID.String(), Code: "x.courtset", IsCourtSet: true},
|
||||
{RuleID: d2ID.String(), Code: "x.short"},
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Concrete rows first (sorted by duration): x.short (14d) then
|
||||
// x.duration (2mo). Then the two no-date rows, tiebroken by code:
|
||||
// x.conditional < x.courtset alphabetically.
|
||||
want := []string{"x.short", "x.duration", "x.conditional", "x.courtset"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged guards
|
||||
// the root-rule exception: top-level rules (parent_id=nil, no
|
||||
// trigger_event_id) must never be sorted against each other — they
|
||||
// represent distinct anchor points (SoC vs oral hearing vs decision)
|
||||
// whose proceeding-sequence order is non-negotiable.
|
||||
func TestSortDeadlinesByDurationWithinTriggerGroup_RootsNotMerged(t *testing.T) {
|
||||
rootSoCID, rootSoCRule := makeRule(t, nil, "x.soc", 0, "months")
|
||||
rootOralID, rootOralRule := makeRule(t, nil, "x.oral", 0, "months")
|
||||
rootDecID, rootDecRule := makeRule(t, nil, "x.decision", 0, "months")
|
||||
|
||||
ruleByID := map[uuid.UUID]models.DeadlineRule{
|
||||
rootSoCID: rootSoCRule, rootOralID: rootOralRule, rootDecID: rootDecRule,
|
||||
}
|
||||
|
||||
deadlines := []UIDeadline{
|
||||
makeDeadline(rootSoCID, "x.soc"),
|
||||
makeDeadline(rootOralID, "x.oral"),
|
||||
makeDeadline(rootDecID, "x.decision"),
|
||||
}
|
||||
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Roots must keep their input order — they're not in the same
|
||||
// trigger group as each other.
|
||||
want := []string{"x.soc", "x.oral", "x.decision"}
|
||||
for i, w := range want {
|
||||
if deadlines[i].Code != w {
|
||||
t.Errorf("deadlines[%d].Code = %q, want %q (roots must not be sorted against each other)", i, deadlines[i].Code, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// Country and regime constants — keep in sync with the paliad.countries
|
||||
@@ -229,38 +231,14 @@ func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, country, regime
|
||||
// Feiertag" — so a 27-day shift across UPC vacation no longer looks like a
|
||||
// math bug. See t-paliad-119.
|
||||
//
|
||||
// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention
|
||||
// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a
|
||||
// separate RFC3339 parser. Holidays carries the same string-date shape.
|
||||
type AdjustmentReason struct {
|
||||
// Kind is the dominant cause; longest cause wins when several apply
|
||||
// (vacation > public_holiday > weekend).
|
||||
Kind string `json:"kind"`
|
||||
// Holidays collects every named holiday encountered while walking past
|
||||
// the non-working run, deduped by (date, name). May be empty when the
|
||||
// only cause is a weekend.
|
||||
Holidays []HolidayDTO `json:"holidays,omitempty"`
|
||||
// VacationName, VacationStart and VacationEnd describe the contiguous
|
||||
// vacation block the original date sits in. Populated only when Kind
|
||||
// == "vacation". Span boundaries are the first/last vacation day in
|
||||
// the block (excludes the weekends that pad it).
|
||||
VacationName string `json:"vacationName,omitempty"`
|
||||
VacationStart string `json:"vacationStart,omitempty"`
|
||||
VacationEnd string `json:"vacationEnd,omitempty"`
|
||||
// OriginalWeekday is the English weekday name of the original date —
|
||||
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
|
||||
// can localise it.
|
||||
OriginalWeekday string `json:"originalWeekday,omitempty"`
|
||||
}
|
||||
|
||||
// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason —
|
||||
// distinct from Holiday so dates serialise as YYYY-MM-DD strings.
|
||||
type HolidayDTO struct {
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
IsVacation bool `json:"isVacation,omitempty"`
|
||||
IsClosure bool `json:"isClosure,omitempty"`
|
||||
}
|
||||
// Canonical AdjustmentReason + HolidayDTO definitions live in
|
||||
// pkg/litigationplanner — kept here as type aliases so every existing
|
||||
// reference (HolidayService methods, JSON serialisation, projection
|
||||
// service) continues to compile.
|
||||
type (
|
||||
AdjustmentReason = litigationplanner.AdjustmentReason
|
||||
HolidayDTO = litigationplanner.HolidayDTO
|
||||
)
|
||||
|
||||
// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
|
||||
// explanation. Reason is nil when wasAdjusted is false.
|
||||
|
||||
@@ -1,191 +1,63 @@
|
||||
package services
|
||||
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in the
|
||||
// codebase: the **litigation** conceptual category (INF / REV / APP /
|
||||
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
|
||||
// + Pipeline-A rules, and the **fristenrechner** code category
|
||||
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
|
||||
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
|
||||
// bind to fristenrechner codes directly, but the litigation→fristenrechner
|
||||
// mapping is still needed for the ~40 Pipeline-A rules that remain on
|
||||
// litigation proceedings and for any other surface that thinks in
|
||||
// litigation terms.
|
||||
//
|
||||
// The mapping table here is the single source of truth — see
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale + ambiguity notes, and
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
|
||||
// lowercase dot-separated naming convention applied by mig 096
|
||||
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
|
||||
// returns ok=false so callers can degrade gracefully ("no narrowing")
|
||||
// instead of guessing.
|
||||
import lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
|
||||
// Stable code constants — the strings landed by mig 096. Use these
|
||||
// throughout the codebase so a future rename only needs to touch this
|
||||
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
|
||||
// projects.proceeding_type_id) are unaffected by the rename.
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in
|
||||
// the codebase. The canonical implementations now live in
|
||||
// pkg/litigationplanner — this file keeps the existing service-level
|
||||
// names alive as re-exports so the rest of internal/services + tests
|
||||
// compile without an import-rewrite.
|
||||
//
|
||||
// See pkg/litigationplanner/proceeding_mapping.go for the logic +
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale.
|
||||
|
||||
// Stable code constants — re-exported from the package so existing
|
||||
// services / handlers can keep using the bare names.
|
||||
const (
|
||||
CodeUPCInfringement = "upc.inf.cfi"
|
||||
CodeUPCRevocation = "upc.rev.cfi"
|
||||
CodeUPCCounterclaim = "upc.ccr.cfi"
|
||||
CodeUPCPreliminary = "upc.pi.cfi"
|
||||
CodeUPCDamages = "upc.dmgs.cfi"
|
||||
CodeUPCDiscovery = "upc.disc.cfi"
|
||||
CodeUPCAppealMerits = "upc.apl.merits"
|
||||
CodeUPCAppealOrder = "upc.apl.order"
|
||||
CodeUPCAppealCost = "upc.apl.cost"
|
||||
CodeDEInfringementLG = "de.inf.lg"
|
||||
CodeDEInfringementOLG = "de.inf.olg"
|
||||
CodeDEInfringementBGH = "de.inf.bgh"
|
||||
CodeDENullityBPatG = "de.null.bpatg"
|
||||
CodeDENullityBGH = "de.null.bgh"
|
||||
CodeEPAGrant = "epa.grant.exa"
|
||||
CodeEPAOpposition = "epa.opp.opd"
|
||||
CodeEPAOppositionAppeal = "epa.opp.boa"
|
||||
CodeDPMAOpposition = "dpma.opp.dpma"
|
||||
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
|
||||
CodeDPMAAppealBGH = "dpma.appeal.bgh"
|
||||
CodeUPCInfringement = lp.CodeUPCInfringement
|
||||
CodeUPCRevocation = lp.CodeUPCRevocation
|
||||
CodeUPCCounterclaim = lp.CodeUPCCounterclaim
|
||||
CodeUPCPreliminary = lp.CodeUPCPreliminary
|
||||
CodeUPCDamages = lp.CodeUPCDamages
|
||||
CodeUPCDiscovery = lp.CodeUPCDiscovery
|
||||
CodeUPCAppealMerits = lp.CodeUPCAppealMerits
|
||||
CodeUPCAppealOrder = lp.CodeUPCAppealOrder
|
||||
CodeUPCAppealCost = lp.CodeUPCAppealCost
|
||||
CodeDEInfringementLG = lp.CodeDEInfringementLG
|
||||
CodeDEInfringementOLG = lp.CodeDEInfringementOLG
|
||||
CodeDEInfringementBGH = lp.CodeDEInfringementBGH
|
||||
CodeDENullityBPatG = lp.CodeDENullityBPatG
|
||||
CodeDENullityBGH = lp.CodeDENullityBGH
|
||||
CodeEPAGrant = lp.CodeEPAGrant
|
||||
CodeEPAOpposition = lp.CodeEPAOpposition
|
||||
CodeEPAOppositionAppeal = lp.CodeEPAOppositionAppeal
|
||||
CodeDPMAOpposition = lp.CodeDPMAOpposition
|
||||
CodeDPMAAppealBPatG = lp.CodeDPMAAppealBPatG
|
||||
CodeDPMAAppealBGH = lp.CodeDPMAAppealBGH
|
||||
)
|
||||
|
||||
// MapLitigationToFristenrechner returns the fristenrechner code +
|
||||
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
||||
//
|
||||
// Inputs are case-sensitive — pass the canonical upper-snake form
|
||||
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
|
||||
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
|
||||
// fristenrechner code; callers should treat that as "no narrowing"
|
||||
// and leave the cascade wide-open rather than auto-pick.
|
||||
//
|
||||
// Condition flags are returned as a slice so callers can apply them
|
||||
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
|
||||
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
|
||||
// context applies.
|
||||
// Delegates to litigationplanner.MapLitigationToFristenrechner.
|
||||
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
|
||||
switch litigationCode {
|
||||
case "INF":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, nil, true
|
||||
case "DE":
|
||||
return CodeDEInfringementLG, nil, true
|
||||
}
|
||||
case "REV":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCRevocation, nil, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "CCR":
|
||||
// Counterclaim revocation — UPC fold-in is structural (the
|
||||
// counterclaim lives inside an upc.inf.cfi proceeding with the
|
||||
// with_ccr flag). DE Nichtigkeit is conceptually the same
|
||||
// adversarial-validity test, no separate flag.
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, []string{"with_ccr"}, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "AMD":
|
||||
// Amendment-application bundled into upc.inf.cfi via with_amend.
|
||||
// No DE / EPA / DPMA analogue today.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCInfringement, []string{"with_amend"}, true
|
||||
}
|
||||
case "APP":
|
||||
// Appeal is ambiguous in DE (OLG vs BGH) and the project
|
||||
// model doesn't carry the instance hint we'd need to
|
||||
// disambiguate. UPC is unambiguous — upc.apl.merits covers
|
||||
// the merits appeal track for inf/rev/ccr/damages.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCAppealMerits, nil, true
|
||||
}
|
||||
case "APM":
|
||||
// Preliminary injunction / urgency procedure — UPC-only
|
||||
// concept in the fristenrechner taxonomy.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCPreliminary, nil, true
|
||||
}
|
||||
case "OPP":
|
||||
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
|
||||
// doesn't surface from the litigation vocabulary today.
|
||||
if jurisdiction == "EPA" {
|
||||
return CodeEPAOpposition, nil, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
return lp.MapLitigationToFristenrechner(litigationCode, jurisdiction)
|
||||
}
|
||||
|
||||
// ResolveCounterclaimRouting handles the determinator's
|
||||
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
|
||||
// for taxonomic completeness, but no rules are attached to it. When the
|
||||
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
|
||||
// upc.inf.cfi with a default with_ccr=true flag — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
|
||||
//
|
||||
// `code` is the proceeding code the cascade resolved to. If it's
|
||||
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
|
||||
// []string{"with_ccr"}, true). For any other code the function returns
|
||||
// (code, nil, false) and callers proceed with the code unchanged. The
|
||||
// boolean signals "routing was applied"; the caller can surface the hint
|
||||
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
|
||||
// weiter." in the UI.
|
||||
// ResolveCounterclaimRouting handles the determinator's upc.ccr.cfi
|
||||
// illustrative-peer route. Delegates to
|
||||
// litigationplanner.ResolveCounterclaimRouting.
|
||||
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
||||
if route, ok := SubTrackRoutings[code]; ok {
|
||||
return route.ParentCode, route.DefaultFlags, true
|
||||
}
|
||||
return code, nil, false
|
||||
return lp.ResolveCounterclaimRouting(code)
|
||||
}
|
||||
|
||||
// SubTrackRouting describes a proceeding type that has no native rules
|
||||
// of its own and is normally rendered inside a parent proceeding's flow
|
||||
// with one or more condition flags enabled. The Procedure Roadmap
|
||||
// (verfahrensablauf) routes calc requests for these codes to the parent
|
||||
// proceeding + default flags, but preserves the user-picked code/name
|
||||
// in the response identity and surfaces a contextual note explaining
|
||||
// the framing — see m/paliad#58 and the design doc cited above.
|
||||
//
|
||||
// Adding a new sub-track is a data-only change here: extend
|
||||
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
|
||||
// renderer picks it up automatically. The note copy lives in this file
|
||||
// because it's semantic to the routing, not UI chrome.
|
||||
type SubTrackRouting struct {
|
||||
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
|
||||
Code string
|
||||
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
|
||||
ParentCode string
|
||||
// DefaultFlags are merged into the user's flag set so the
|
||||
// gated rules render. Order is preserved.
|
||||
DefaultFlags []string
|
||||
// NoteDE / NoteEN are the contextual banner above the timeline,
|
||||
// explaining that the proceeding type is normally a sub-track.
|
||||
// Plain text — the frontend renders them as a banner.
|
||||
NoteDE string
|
||||
NoteEN string
|
||||
}
|
||||
|
||||
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
|
||||
// The pattern generalises to other "sub-track" proceeding types (e.g.
|
||||
// R.30 application to amend the patent as a standalone roadmap, R.46
|
||||
// preliminary objection) once they have a proceeding-type code of their
|
||||
// own. New entries here are picked up by the spawn-as-standalone
|
||||
// renderer in FristenrechnerService.Calculate without further wiring.
|
||||
var SubTrackRoutings = map[string]SubTrackRouting{
|
||||
CodeUPCCounterclaim: {
|
||||
Code: CodeUPCCounterclaim,
|
||||
ParentCode: CodeUPCInfringement,
|
||||
DefaultFlags: []string{"with_ccr"},
|
||||
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
|
||||
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
|
||||
},
|
||||
}
|
||||
// SubTrackRoutings exposes the sub-track routing registry. SubTrackRouting
|
||||
// is aliased in fristenrechner.go.
|
||||
var SubTrackRoutings = lp.SubTrackRoutings
|
||||
|
||||
// LookupSubTrackRouting returns the sub-track routing for a proceeding
|
||||
// code, or (zero, false) if the code is not a sub-track. Used by the
|
||||
// fristenrechner Calculate path to spawn the parent flow with the sub-
|
||||
// track's default flags.
|
||||
// code, or (zero, false) if the code is not a sub-track. Delegates to
|
||||
// litigationplanner.LookupSubTrackRouting.
|
||||
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
|
||||
r, ok := SubTrackRoutings[code]
|
||||
return r, ok
|
||||
return lp.LookupSubTrackRouting(code)
|
||||
}
|
||||
|
||||
@@ -604,92 +604,6 @@ func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ExportMigrationsSince returns a SQL blob containing one UPDATE / INSERT
|
||||
// per audited rule change after the given audit row id. Used by the
|
||||
// admin "export changes to a migration file" flow (Q-H-5: pure SQL
|
||||
// format). Returns SQL + count + the latest audit id seen so the
|
||||
// caller can pass it as ?since= on the next call.
|
||||
//
|
||||
// v1 generates one UPDATE per audit row using the after_json snapshot.
|
||||
// Slice 11b will polish the output (re-order so foreign-key edges
|
||||
// resolve, collapse consecutive UPDATEs on the same row, format the
|
||||
// header comment with author + reason). v1 emits one statement per
|
||||
// audit row in chronological order — sufficient for hand-review.
|
||||
type ExportResult struct {
|
||||
MigrationSQL string `json:"migration_sql"`
|
||||
Count int `json:"count"`
|
||||
LatestAuditID string `json:"latest_audit_id"`
|
||||
}
|
||||
|
||||
func (s *RuleEditorService) ExportMigrationsSince(ctx context.Context, sinceAuditID string) (*ExportResult, error) {
|
||||
type auditRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
RuleID uuid.UUID `db:"rule_id"`
|
||||
ChangedAt time.Time `db:"changed_at"`
|
||||
Action string `db:"action"`
|
||||
AfterJSON json.RawMessage `db:"after_json"`
|
||||
Reason string `db:"reason"`
|
||||
}
|
||||
var rows []auditRow
|
||||
q := `SELECT id, rule_id, changed_at, action, after_json, reason
|
||||
FROM paliad.deadline_rule_audit
|
||||
WHERE migration_exported = false`
|
||||
args := []any{}
|
||||
if sinceAuditID != "" {
|
||||
sid, err := uuid.Parse(sinceAuditID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid since= uuid", ErrInvalidInput)
|
||||
}
|
||||
q += ` AND changed_at >= (SELECT changed_at FROM paliad.deadline_rule_audit WHERE id = $1)`
|
||||
args = append(args, sid)
|
||||
}
|
||||
q += ` ORDER BY changed_at ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list audit since: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("-- Auto-generated rule-editor migration export.\n")
|
||||
sb.WriteString("-- Generated at: " + time.Now().UTC().Format(time.RFC3339) + "\n")
|
||||
sb.WriteString("-- Rows: " + fmt.Sprintf("%d", len(rows)) + "\n\n")
|
||||
sb.WriteString("SELECT set_config('paliad.audit_reason',\n")
|
||||
sb.WriteString(" 'rule-editor export: replay of " + fmt.Sprintf("%d", len(rows)) + " edits', true);\n\n")
|
||||
|
||||
latest := ""
|
||||
for _, r := range rows {
|
||||
sb.WriteString("-- audit " + r.ID.String() + " (" + r.Action + " " + r.ChangedAt.Format(time.RFC3339) + "): " + sqlEscape(r.Reason) + "\n")
|
||||
switch r.Action {
|
||||
case "create", "update":
|
||||
if len(r.AfterJSON) == 0 {
|
||||
sb.WriteString("-- (no after_json — skipped)\n\n")
|
||||
continue
|
||||
}
|
||||
sb.WriteString("INSERT INTO paliad.deadline_rules\n")
|
||||
sb.WriteString(" SELECT (jsonb_populate_record(NULL::paliad.deadline_rules, '")
|
||||
sb.WriteString(sqlEscape(string(r.AfterJSON)))
|
||||
sb.WriteString("'::jsonb)).*\n")
|
||||
sb.WriteString("ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, name_en = EXCLUDED.name_en,\n")
|
||||
sb.WriteString(" duration_value = EXCLUDED.duration_value, duration_unit = EXCLUDED.duration_unit,\n")
|
||||
sb.WriteString(" timing = EXCLUDED.timing, priority = EXCLUDED.priority,\n")
|
||||
sb.WriteString(" is_court_set = EXCLUDED.is_court_set,\n")
|
||||
sb.WriteString(" condition_expr = EXCLUDED.condition_expr,\n")
|
||||
sb.WriteString(" lifecycle_state = EXCLUDED.lifecycle_state,\n")
|
||||
sb.WriteString(" updated_at = now();\n\n")
|
||||
case "delete", "archive":
|
||||
sb.WriteString("UPDATE paliad.deadline_rules SET lifecycle_state='archived', updated_at=now() WHERE id='")
|
||||
sb.WriteString(r.RuleID.String())
|
||||
sb.WriteString("';\n\n")
|
||||
}
|
||||
latest = r.ID.String()
|
||||
}
|
||||
|
||||
return &ExportResult{
|
||||
MigrationSQL: sb.String(),
|
||||
Count: len(rows),
|
||||
LatestAuditID: latest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal helpers
|
||||
// =============================================================================
|
||||
@@ -814,6 +728,3 @@ func nullableJSON(b json.RawMessage) any {
|
||||
return []byte(b)
|
||||
}
|
||||
|
||||
func sqlEscape(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
49
pkg/litigationplanner/catalog.go
Normal file
49
pkg/litigationplanner/catalog.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package litigationplanner
|
||||
|
||||
import "context"
|
||||
|
||||
// Catalog supplies proceeding-type metadata + rules for the calculator.
|
||||
//
|
||||
// Implementations:
|
||||
// - paliad: reads paliad.deadline_rules + paliad.proceeding_types,
|
||||
// filtered to lifecycle_state='published' AND is_active=true.
|
||||
// ProjectHint scopes future per-project rule merges.
|
||||
// - embedded/upc (Slice C): in-memory map keyed by code, populated
|
||||
// once at init from the embedded JSON snapshot.
|
||||
//
|
||||
// All methods return ErrUnknownProceedingType / ErrUnknownRule when the
|
||||
// caller asks for a code/id that doesn't exist in the catalog.
|
||||
type Catalog interface {
|
||||
// LoadProceeding returns the proceeding-type metadata + the full
|
||||
// rule list (sorted by sequence_order). Caller passes the user-
|
||||
// facing proceeding code (e.g. "upc.inf.cfi"). The hint scopes a
|
||||
// future per-project rule merge — implementations that don't
|
||||
// support projects ignore it.
|
||||
LoadProceeding(ctx context.Context, code string, hint ProjectHint) (*ProceedingType, []Rule, error)
|
||||
|
||||
// LoadProceedingByID is the resolver used by CalculateRule when it
|
||||
// has a rule + needs the rule's parent proceeding metadata.
|
||||
LoadProceedingByID(ctx context.Context, id int) (*ProceedingType, error)
|
||||
|
||||
// LoadRuleByID resolves a rule UUID to the rule row. Used by
|
||||
// CalculateRule when the caller supplies CalcRuleParams.RuleID.
|
||||
LoadRuleByID(ctx context.Context, ruleID string) (*Rule, error)
|
||||
|
||||
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode)
|
||||
// + returns the parent proceeding for use in the response identity.
|
||||
// Used by CalculateRule when the caller supplies the (code, local)
|
||||
// pair from a concept-card pill.
|
||||
LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*Rule, *ProceedingType, error)
|
||||
|
||||
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted
|
||||
// rules (rules whose trigger_event_id matches). Used by
|
||||
// EventDeadlineService → Calculate via CalcOptions.TriggerEventIDFilter.
|
||||
LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]Rule, error)
|
||||
|
||||
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows
|
||||
// for the conditional-label override (t-paliad-294 /
|
||||
// m/paliad#126). Returns a map keyed by event id; missing ids
|
||||
// are simply absent (caller treats absence as "no override").
|
||||
// Empty input returns an empty map without a DB roundtrip.
|
||||
LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error)
|
||||
}
|
||||
49
pkg/litigationplanner/courts.go
Normal file
49
pkg/litigationplanner/courts.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package litigationplanner
|
||||
|
||||
// CourtRegistry maps a court id (e.g. "upc-ld-paris", "de-bgh") to its
|
||||
// (country, regime) tuple, which drives non-working-day adjustment.
|
||||
//
|
||||
// Implementations:
|
||||
// - paliad: reads paliad.courts (CourtService.CountryRegime).
|
||||
// - embedded/upc (Slice C): in-memory map populated from the embedded
|
||||
// JSON snapshot.
|
||||
//
|
||||
// Empty courtID falls back to (defaultCountry, defaultRegime) so callers
|
||||
// without a court_id (the abstract Verfahrensablauf path) still get
|
||||
// sensible behaviour. Returns an error when courtID is non-empty and
|
||||
// not in the registry.
|
||||
type CourtRegistry interface {
|
||||
CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error)
|
||||
}
|
||||
|
||||
// Country and regime constants — keep in sync with the paliad.countries
|
||||
// seed list and the holidays_regime_chk / courts_regime_chk constraints.
|
||||
const (
|
||||
CountryDE = "DE"
|
||||
RegimeUPC = "UPC"
|
||||
RegimeEPO = "EPO"
|
||||
)
|
||||
|
||||
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
|
||||
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple
|
||||
// a holiday lookup should default to when the caller didn't pass an
|
||||
// explicit CourtID. UPC proceedings get DE+UPC (München LD is HLC's
|
||||
// most common venue, German federal holidays plus UPC vacations apply);
|
||||
// DE / DPMA / EPA get DE-only (German federal). Future EPA-specific
|
||||
// closures will require callers to pick an EPA court explicitly so the
|
||||
// EPO regime kicks in.
|
||||
//
|
||||
// Helper kept tiny and stateless — when a caller passes a real CourtID,
|
||||
// these defaults are bypassed entirely and the court's actual country +
|
||||
// regime are used.
|
||||
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
|
||||
if jurisdiction == nil {
|
||||
return CountryDE, ""
|
||||
}
|
||||
switch *jurisdiction {
|
||||
case "UPC":
|
||||
return CountryDE, RegimeUPC
|
||||
default:
|
||||
return CountryDE, ""
|
||||
}
|
||||
}
|
||||
17
pkg/litigationplanner/doc.go
Normal file
17
pkg/litigationplanner/doc.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package litigationplanner is the canonical Fristen / Verfahrensablauf
|
||||
// compute engine — the deadline-rule model, the calendar arithmetic, the
|
||||
// condition-expression gate, the sub-track routing, and the timeline
|
||||
// composer that drives Paliad's /tools/fristenrechner,
|
||||
// /tools/verfahrensablauf, and the per-project SmartTimeline.
|
||||
//
|
||||
// The package owns its types (Rule, ProceedingType, Timeline,
|
||||
// TimelineEntry, CalcOptions, …) and exposes three interfaces for the
|
||||
// stateful inputs: Catalog (proceeding + rule lookup), HolidayCalendar
|
||||
// (non-working-day adjustment), and CourtRegistry (court → country/regime
|
||||
// resolution). Paliad implements them against its Postgres database;
|
||||
// downstream consumers (youpc.org) implement them against an embedded
|
||||
// JSON snapshot of the UPC subset.
|
||||
//
|
||||
// See docs/design-litigation-planner-2026-05-26.md (t-paliad-292 /
|
||||
// m/paliad#124) for the full design.
|
||||
package litigationplanner
|
||||
76
pkg/litigationplanner/durations.go
Normal file
76
pkg/litigationplanner/durations.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package litigationplanner
|
||||
|
||||
import "time"
|
||||
|
||||
// ApplyDuration is the unified date-arithmetic helper used by every
|
||||
// calculator path (proceeding-tree, trigger-event, CalculateRule single-
|
||||
// rule). Phase 3 Slice 4 (t-paliad-185) replaced the prior split
|
||||
// between addDuration (proceeding-tree, no timing / working_days) and
|
||||
// ApplyDurationOnCalendar (Pipeline-C, full support) with this single
|
||||
// helper.
|
||||
//
|
||||
// Returns (raw, adjusted, didAdjust, reason):
|
||||
//
|
||||
// - raw: the date strictly implied by the rule before rollover.
|
||||
// - adjusted: post-rollover for calendar units. 'working_days' lands
|
||||
// on a working day by construction so raw == adjusted there.
|
||||
// - didAdjust: true iff rollover moved the date.
|
||||
// - reason: populated when didAdjust is true; nil otherwise.
|
||||
//
|
||||
// timing='before' negates the sign. timing='after' (or any other value
|
||||
// including the empty string) keeps it positive — preserves the pre-
|
||||
// Slice-4 behaviour for proceeding-tree rules whose Timing field is
|
||||
// sometimes NULL (mig 003 defaults to 'after' but legacy callers pass
|
||||
// r.Timing dereferenced).
|
||||
func ApplyDuration(
|
||||
base time.Time, value int, unit, timing, country, regime string, holidays HolidayCalendar,
|
||||
) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
}
|
||||
switch unit {
|
||||
case "days":
|
||||
raw = base.AddDate(0, 0, sign*value)
|
||||
case "weeks":
|
||||
raw = base.AddDate(0, 0, sign*value*7)
|
||||
case "months":
|
||||
raw = base.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
raw = AddWorkingDays(base, sign*value, country, regime, holidays)
|
||||
// Working-day arithmetic lands on a working day by construction
|
||||
// — the per-step skip loop in AddWorkingDays already passes over
|
||||
// weekends and holidays. No post-rollover required.
|
||||
return raw, raw, false, nil
|
||||
default:
|
||||
raw = base
|
||||
}
|
||||
adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime)
|
||||
return raw, adjusted, didAdjust, reason
|
||||
}
|
||||
|
||||
// AddWorkingDays advances from `from` by `n` working days, skipping
|
||||
// weekends and holidays applicable to the given country/regime. Negative
|
||||
// n walks backward. n=0 keeps the input date as-is (caller decides
|
||||
// whether to roll forward via AdjustForNonWorkingDays).
|
||||
//
|
||||
// Bounded by an inner 30-step skip per advance — vacation runs in our
|
||||
// holiday tables are < 14 consecutive days, so 30 is a safety margin.
|
||||
func AddWorkingDays(from time.Time, n int, country, regime string, holidays HolidayCalendar) time.Time {
|
||||
if n == 0 {
|
||||
return from
|
||||
}
|
||||
step := 1
|
||||
if n < 0 {
|
||||
step = -1
|
||||
n = -n
|
||||
}
|
||||
cur := from
|
||||
for i := 0; i < n; i++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
908
pkg/litigationplanner/engine.go
Normal file
908
pkg/litigationplanner/engine.go
Normal file
@@ -0,0 +1,908 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
// Preserves the pre-Phase-C in-memory calculator's classification:
|
||||
//
|
||||
// - Rules with duration_value = 0 and no parent_id → IsRootEvent
|
||||
// (due date = trigger date)
|
||||
// - Rules with duration_value = 0 and a parent_id → IsCourtSet
|
||||
// (due date empty, UI shows "court-set" placeholder)
|
||||
// - All other rules → calculate from either the trigger date (no parent)
|
||||
// or the previously-computed date for their parent rule.
|
||||
//
|
||||
// Audit-driven extensions:
|
||||
//
|
||||
// - opts.Flags can flip flag-conditioned rules onto their alt_* values
|
||||
// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr").
|
||||
// - opts.PriorityDateStr overrides the anchor for rules with
|
||||
// anchor_alt='priority_date' (e.g. epa.grant.exa publication date
|
||||
// is 18mo from priority, not filing).
|
||||
// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the
|
||||
// caller redirect a downstream rule's parent anchor to a user-set
|
||||
// date.
|
||||
func Calculate(
|
||||
ctx context.Context,
|
||||
proceedingCode string,
|
||||
triggerDateStr string,
|
||||
opts CalcOptions,
|
||||
catalog Catalog,
|
||||
holidays HolidayCalendar,
|
||||
courts CourtRegistry,
|
||||
) (*Timeline, error) {
|
||||
// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
|
||||
// branch (Pipeline-C unified rules). proceedingCode is ignored on
|
||||
// this path.
|
||||
if opts.TriggerEventIDFilter != nil {
|
||||
return calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts, catalog, holidays, courts)
|
||||
}
|
||||
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
}
|
||||
|
||||
var priorityDate *time.Time
|
||||
if opts.PriorityDateStr != "" {
|
||||
pd, err := time.Parse("2006-01-02", opts.PriorityDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err)
|
||||
}
|
||||
priorityDate = &pd
|
||||
}
|
||||
flagSet := make(map[string]struct{}, len(opts.Flags))
|
||||
for _, f := range opts.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
// v1 simplification (t-paliad-265): when any IncludeCCRFor entry
|
||||
// exists, we treat with_ccr as set in the flag context.
|
||||
if len(opts.IncludeCCRFor) > 0 {
|
||||
flagSet["with_ccr"] = struct{}{}
|
||||
}
|
||||
|
||||
// Parse anchor overrides up-front so a malformed date errors out
|
||||
// before we start walking rules.
|
||||
overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides))
|
||||
for code, dateStr := range opts.AnchorOverrides {
|
||||
od, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err)
|
||||
}
|
||||
overrideDates[code] = od
|
||||
}
|
||||
|
||||
// Look up proceeding type metadata.
|
||||
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sub-track routing (m/paliad#58). When the user picks a proceeding
|
||||
// that has no native rules and is normally a sub-track of another
|
||||
// proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route
|
||||
// rule lookup to the parent and merge the default flags into the
|
||||
// user's flag set. The response identity stays on the user-picked
|
||||
// proceeding so the page header still reads "Counterclaim for
|
||||
// Revocation", but the timeline body is the parent's full flow with
|
||||
// the sub-track flag enabled.
|
||||
var subTrackNote SubTrackRouting
|
||||
var hasSubTrackNote bool
|
||||
pt := pickedProceeding
|
||||
if route, ok := LookupSubTrackRouting(proceedingCode); ok {
|
||||
subTrackNote = route
|
||||
hasSubTrackNote = true
|
||||
parentPt, parentRules, err := catalog.LoadProceeding(ctx, route.ParentCode, opts.ProjectHint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, err)
|
||||
}
|
||||
pt = parentPt
|
||||
rules = parentRules
|
||||
// Merge default flags into the user's flag set so the gated
|
||||
// rules render. User-supplied flags win on conflict.
|
||||
for _, f := range route.DefaultFlags {
|
||||
if _, exists := flagSet[f]; !exists {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve (country, regime) for non-working-day adjustment. Court
|
||||
// wins when supplied; otherwise default by proceeding regime.
|
||||
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
||||
country, regime, err := courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
||||
}
|
||||
|
||||
if len(opts.RuleOverrides) > 0 {
|
||||
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
|
||||
}
|
||||
|
||||
// ruleByID lets the conditional-rendering branches resolve a parent
|
||||
// rule's display fields (submission_code, name, name_en) for the
|
||||
// "abhängig von <ParentRuleName>" chip without re-scanning the rules
|
||||
// slice on every iteration. (t-paliad-289)
|
||||
ruleByID := make(map[uuid.UUID]Rule, len(rules))
|
||||
for _, r := range rules {
|
||||
ruleByID[r.ID] = r
|
||||
}
|
||||
|
||||
// triggerEventByID powers the trigger-event override on the
|
||||
// conditional-label chip (m/paliad#126 / t-paliad-294). When a rule
|
||||
// carries a real paliad.trigger_events row, that catalog event —
|
||||
// not the rule's parent_id — is the rule's actual semantic anchor.
|
||||
// The override fires below when stamping ParentRule* on the wire so
|
||||
// the chip reads e.g. "abhängig von Antrag auf Vertraulichkeit
|
||||
// gegenüber der Öffentlichkeit" for R.262(2) — instead of the
|
||||
// (misleading) parent_id-derived "abhängig von Klageerhebung".
|
||||
//
|
||||
// Bulk-loaded in one round-trip; trees in the live corpus carry at
|
||||
// most a handful of trigger_event_id-bearing rules (2 today on
|
||||
// upc.inf.cfi), so the IN(...) is small.
|
||||
var triggerIDs []int64
|
||||
seenTrigger := make(map[int64]struct{}, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.TriggerEventID == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenTrigger[*r.TriggerEventID]; ok {
|
||||
continue
|
||||
}
|
||||
seenTrigger[*r.TriggerEventID] = struct{}{}
|
||||
triggerIDs = append(triggerIDs, *r.TriggerEventID)
|
||||
}
|
||||
triggerEventByID, err := catalog.LoadTriggerEventsByIDs(ctx, triggerIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
|
||||
}
|
||||
|
||||
// Walk the rule list in sequence_order (already sorted by the
|
||||
// catalog query) and compute each entry, keeping a code→date map so
|
||||
// RelativeTo / parent_id references resolve to the adjusted
|
||||
// predecessor date.
|
||||
computed := make(map[string]time.Time, len(rules))
|
||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||
deadlines := make([]TimelineEntry, 0, len(rules))
|
||||
|
||||
skipRules := opts.SkipRules
|
||||
perCardAppellant := opts.PerCardAppellant
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
hiddenCount := 0
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb).
|
||||
// Suppression semantic preserved: when the gate fires false
|
||||
// AND no alt_* values exist, the rule is dropped from the
|
||||
// timeline entirely (purely conditional). When alt_* values
|
||||
// exist, the gate-false branch still renders, just without
|
||||
// the alt-swap.
|
||||
gateMet := EvalConditionExpr([]byte(r.ConditionExpr), flagSet)
|
||||
if !gateMet && r.AltDurationValue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// SkipRules suppression (t-paliad-265).
|
||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||
// we re-surface the directly-skipped row (faded via IsHidden)
|
||||
// instead of dropping it.
|
||||
var isHidden bool
|
||||
if r.SubmissionCode != nil {
|
||||
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
||||
hiddenCount++
|
||||
if !opts.IncludeHidden {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
isHidden = true
|
||||
}
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// AppellantContext propagation. A rule with its own
|
||||
// PerCardAppellant pick stamps its UUID with that value.
|
||||
// Otherwise inherit from parent if the parent had a context.
|
||||
var ctxVal string
|
||||
if r.SubmissionCode != nil {
|
||||
if v, ok := perCardAppellant[*r.SubmissionCode]; ok {
|
||||
ctxVal = v
|
||||
}
|
||||
}
|
||||
if ctxVal == "" && r.ParentID != nil {
|
||||
if v, ok := appellantContext[*r.ParentID]; ok {
|
||||
ctxVal = v
|
||||
}
|
||||
}
|
||||
if ctxVal != "" {
|
||||
appellantContext[r.ID] = ctxVal
|
||||
}
|
||||
|
||||
d := TimelineEntry{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
AppellantContext: ctxVal,
|
||||
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
||||
IsHidden: isHidden,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
}
|
||||
if r.PrimaryParty != nil {
|
||||
d.Party = *r.PrimaryParty
|
||||
}
|
||||
if r.RuleCode != nil {
|
||||
d.RuleRef = *r.RuleCode
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
||||
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
}
|
||||
if r.DeadlineNotesEn != nil {
|
||||
d.NotesEN = *r.DeadlineNotesEn
|
||||
}
|
||||
|
||||
// Resolve the parent rule once so every conditional-rendering
|
||||
// branch (incl. the optional-not-recorded path below) can stamp
|
||||
// ParentRule* on the wire without re-scanning. Populated even
|
||||
// for non-conditional rows — the frontend dependency-footer
|
||||
// ("Folgt aus …") already consumes this on regular projected
|
||||
// rows. (t-paliad-289)
|
||||
var parentRule *Rule
|
||||
if r.ParentID != nil {
|
||||
if pr, ok := ruleByID[*r.ParentID]; ok {
|
||||
parentRule = &pr
|
||||
if pr.SubmissionCode != nil {
|
||||
d.ParentRuleCode = *pr.SubmissionCode
|
||||
}
|
||||
d.ParentRuleName = pr.Name
|
||||
d.ParentRuleNameEN = pr.NameEN
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger-event override on the user-facing dependency identity
|
||||
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
||||
// trigger_event_id, that catalog event is the actual semantic
|
||||
// anchor — not the parent_id node, which is only the calc-time
|
||||
// arithmetic anchor. Only the user-facing wire fields shift;
|
||||
// parentRule (and the parent_id chain feeding parentIsCourtSet
|
||||
// and the calc-time arithmetic below) stays anchored on the
|
||||
// rule tree.
|
||||
if r.TriggerEventID != nil {
|
||||
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
||||
d.ParentRuleCode = te.Code
|
||||
d.ParentRuleName = te.NameDE
|
||||
d.ParentRuleNameEN = te.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate court-set status from a parent rule whose date the
|
||||
// court determines: if the anchor itself has no real date,
|
||||
// nothing downstream can be computed either — UNLESS the user
|
||||
// has supplied an override date for the parent.
|
||||
parentOverridden := false
|
||||
if r.ParentID != nil && courtSet[*r.ParentID] {
|
||||
for _, prev := range rules {
|
||||
if prev.ID == *r.ParentID {
|
||||
if prev.SubmissionCode != nil {
|
||||
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
|
||||
parentOverridden = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden
|
||||
|
||||
// Zero-duration rules fall into one of four buckets:
|
||||
// 1. parent=nil, not court-determined → IsRootEvent (trigger anchor)
|
||||
// 2. parent=nil, court-determined → IsCourtSet
|
||||
// 3. parent set, court-determined → IsCourtSet (waypoint)
|
||||
// 4. parent set, NOT court-determined → "filed-with-parent"
|
||||
//
|
||||
// AnchorOverrides: when the user has set a date for any zero-
|
||||
// duration rule, that override wins over both the court-set
|
||||
// placeholder and the parent-inheritance.
|
||||
if r.DurationValue == 0 {
|
||||
if r.SubmissionCode != nil {
|
||||
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
|
||||
d.DueDate = ov.Format("2006-01-02")
|
||||
d.OriginalDate = d.DueDate
|
||||
d.IsOverridden = true
|
||||
computed[*r.SubmissionCode] = ov
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if r.ParentID == nil && !r.IsCourtSet {
|
||||
// Bucket 1: timeline anchor.
|
||||
d.IsRootEvent = true
|
||||
d.DueDate = triggerDateStr
|
||||
d.OriginalDate = triggerDateStr
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = triggerDate
|
||||
}
|
||||
} else if r.ParentID != nil && !r.IsCourtSet {
|
||||
// Bucket 4: filed-with-parent. Inherit parent's date.
|
||||
if parentIsCourtSet {
|
||||
// Indirect: rule isn't itself court-determined,
|
||||
// it's blocked because its parent is.
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
} else {
|
||||
var parentDate time.Time
|
||||
var haveParentDate bool
|
||||
for _, prev := range rules {
|
||||
if prev.ID == *r.ParentID {
|
||||
if prev.SubmissionCode != nil {
|
||||
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
|
||||
parentDate = ov
|
||||
haveParentDate = true
|
||||
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
|
||||
parentDate = ref
|
||||
haveParentDate = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if haveParentDate {
|
||||
d.DueDate = parentDate.Format("2006-01-02")
|
||||
d.OriginalDate = d.DueDate
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = parentDate
|
||||
}
|
||||
} else {
|
||||
// Parent not yet computed (defensive).
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Buckets 2 + 3: court-determined directly.
|
||||
d.IsCourtSet = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
|
||||
// If the parent is court-determined and not overridden we have
|
||||
// no real anchor date; surface this rule as court-set too
|
||||
// rather than fabricating one off the trigger date. IsConditional
|
||||
// surfaces the "abhängig von <ParentRuleName>" UX (t-paliad-289).
|
||||
if parentIsCourtSet {
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
|
||||
// Anchor: prefer alt-anchor (e.g. priority_date for
|
||||
// epa.grant.exa publish) when supplied, then parent's computed
|
||||
// date (or user override), then trigger date.
|
||||
baseDate := triggerDate
|
||||
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||
baseDate = *priorityDate
|
||||
} else if r.ParentID != nil {
|
||||
for _, prev := range rules {
|
||||
if prev.ID == *r.ParentID {
|
||||
if prev.SubmissionCode != nil {
|
||||
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
|
||||
baseDate = ov
|
||||
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
|
||||
baseDate = ref
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag-conditioned alt-swap (legacy with_ccr pattern): when the
|
||||
// gate fires AND alt_* values exist, swap the primary duration
|
||||
// to the alt values. This is distinct from combine_op below —
|
||||
// alt-swap is a one-or-the-other choice keyed on flags, whereas
|
||||
// combine_op computes both legs and picks max/min.
|
||||
durationValue := r.DurationValue
|
||||
durationUnit := r.DurationUnit
|
||||
timing := ""
|
||||
if r.Timing != nil {
|
||||
timing = *r.Timing
|
||||
}
|
||||
if r.CombineOp == nil && gateMet && HasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
|
||||
durationValue = *r.AltDurationValue
|
||||
if r.AltDurationUnit != nil {
|
||||
durationUnit = *r.AltDurationUnit
|
||||
}
|
||||
if r.AltRuleCode != nil {
|
||||
d.RuleRef = *r.AltRuleCode
|
||||
}
|
||||
}
|
||||
|
||||
// User override on this rule: replace the calculated date with
|
||||
// the user's date. Skip holiday rollover — the user's date is
|
||||
// authoritative.
|
||||
if r.SubmissionCode != nil {
|
||||
if ov, ok := overrideDates[*r.SubmissionCode]; ok {
|
||||
d.OriginalDate = ov.Format("2006-01-02")
|
||||
d.DueDate = ov.Format("2006-01-02")
|
||||
d.WasAdjusted = false
|
||||
d.AdjustmentReason = nil
|
||||
d.IsOverridden = true
|
||||
computed[*r.SubmissionCode] = ov
|
||||
deadlines = append(deadlines, d)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
origDate, adjusted, wasAdj, reason := ApplyDuration(
|
||||
baseDate, durationValue, durationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
|
||||
// combine_op composite: compute the alt leg too, apply max/min.
|
||||
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
||||
altOrig, altAdj, altWasAdj, altReason := ApplyDuration(
|
||||
baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
switch *r.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(adjusted) {
|
||||
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(adjusted) {
|
||||
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.OriginalDate = origDate.Format("2006-01-02")
|
||||
d.DueDate = adjusted.Format("2006-01-02")
|
||||
d.WasAdjusted = wasAdj
|
||||
d.AdjustmentReason = reason
|
||||
|
||||
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
|
||||
// Rules with priority='optional' AND primary_party='both' whose
|
||||
// data-model parent is the proceeding's trigger anchor (parent
|
||||
// has parent_id=NULL and is not court-set, i.e. the SoC root
|
||||
// rule) represent a rule whose REAL triggering event sits
|
||||
// outside the rule data — e.g. R.262(2) Erwiderung auf
|
||||
// Vertraulichkeitsantrag anchors on SoC in the data, but the
|
||||
// real trigger is the opposing party's confidentiality motion
|
||||
// which may never happen. Without an explicit anchor on the
|
||||
// rule itself, the projection must NOT claim a concrete date.
|
||||
if !d.IsOverridden && !d.IsConditional &&
|
||||
r.Priority == "optional" &&
|
||||
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
|
||||
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
d.WasAdjusted = false
|
||||
d.AdjustmentReason = nil
|
||||
// Mark this rule's ID as having an uncertain anchor so
|
||||
// rules chaining off it also surface conditional via the
|
||||
// parentIsCourtSet path.
|
||||
courtSet[r.ID] = true
|
||||
}
|
||||
|
||||
if r.SubmissionCode != nil {
|
||||
computed[*r.SubmissionCode] = adjusted
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
// t-paliad-296: within consecutive runs of rules sharing the same
|
||||
// trigger group (parent_id + trigger_event_id), reorder by duration
|
||||
// ascending so optional events following the same anchor render in
|
||||
// their likely-sequence order. Different trigger groups keep their
|
||||
// proceeding-sequence position — the chunk walk only sorts adjacent
|
||||
// same-group rows. Court-set / conditional rows sort LAST.
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding`.
|
||||
if pickedProceeding.TriggerEventLabelDE != nil {
|
||||
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
|
||||
}
|
||||
if pickedProceeding.TriggerEventLabelEN != nil {
|
||||
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
|
||||
}
|
||||
if hasSubTrackNote {
|
||||
resp.ContextualNote = subTrackNote.NoteDE
|
||||
resp.ContextualNoteEN = subTrackNote.NoteEN
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// calculateByTriggerEvent renders the Pipeline-C timeline for an event
|
||||
// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id
|
||||
// chains), have no flag gating, no priority_date alt-anchor, no party
|
||||
// classification, and no IsRootEvent / IsCourtSet semantics. The math
|
||||
// is just: base + (timing-signed) duration → optional alt-leg combine
|
||||
// → optional weekend/holiday rollover for calendar units.
|
||||
//
|
||||
// Timeline.ProceedingType / ProceedingName stay empty —
|
||||
// EventDeadlineService owns the trigger-event metadata.
|
||||
func calculateByTriggerEvent(
|
||||
ctx context.Context,
|
||||
triggerEventID int64,
|
||||
triggerDateStr string,
|
||||
opts CalcOptions,
|
||||
catalog Catalog,
|
||||
holidays HolidayCalendar,
|
||||
courts CourtRegistry,
|
||||
) (*Timeline, error) {
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
}
|
||||
|
||||
// Pipeline-C rules originate from youpc's UPC-flavoured deadline
|
||||
// corpus — DE / UPC defaults match the legacy EventDeadlineService.
|
||||
country, regime, err := courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
||||
}
|
||||
|
||||
rules, err := catalog.LoadRulesByTriggerEvent(ctx, triggerEventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(opts.RuleOverrides) > 0 {
|
||||
rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
|
||||
}
|
||||
|
||||
deadlines := make([]TimelineEntry, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
timing := ""
|
||||
if r.Timing != nil {
|
||||
timing = *r.Timing
|
||||
}
|
||||
baseRaw, baseAdj, baseChanged, baseReason := ApplyDuration(
|
||||
triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
picked := baseAdj
|
||||
original := baseRaw
|
||||
wasAdj := baseChanged
|
||||
reason := baseReason
|
||||
|
||||
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
||||
altRaw, altAdj, altChanged, altReason := ApplyDuration(
|
||||
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
switch *r.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(baseAdj) {
|
||||
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(baseAdj) {
|
||||
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d := TimelineEntry{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
DueDate: picked.Format("2006-01-02"),
|
||||
OriginalDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdj,
|
||||
AdjustmentReason: reason,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
}
|
||||
if r.PrimaryParty != nil {
|
||||
d.Party = *r.PrimaryParty
|
||||
}
|
||||
if r.RuleCode != nil {
|
||||
d.RuleRef = *r.RuleCode
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
|
||||
d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
}
|
||||
if r.DeadlineNotesEn != nil {
|
||||
d.NotesEN = *r.DeadlineNotesEn
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
return &Timeline{
|
||||
// Trigger-event responses don't carry proceeding metadata —
|
||||
// EventDeadlineService.Calculate fills the trigger fields in
|
||||
// the legacy CalculateResponse shape. Leaving these empty is
|
||||
// the stable contract.
|
||||
ProceedingType: "",
|
||||
ProceedingName: "",
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CalculateRule computes a single deadline from a rule + trigger date.
|
||||
// Used by the v4 result-card click flow. Distinct from Calculate: no
|
||||
// parent-chain walk, no full-timeline rendering — just one date out.
|
||||
//
|
||||
// When the rule is court-determined, DueDate is empty and
|
||||
// IsCourtSet=true; the caller should disable the "Add to project" CTA.
|
||||
//
|
||||
// When the rule has a condition_expr gate and the caller's Flags
|
||||
// satisfy it AND alt_duration_value is non-NULL, the calc swaps to
|
||||
// alt_*. When the gate is not satisfied, the calc still proceeds with
|
||||
// the base duration_value and surfaces FlagsRequired.
|
||||
func CalculateRule(
|
||||
ctx context.Context,
|
||||
params CalcRuleParams,
|
||||
catalog Catalog,
|
||||
holidays HolidayCalendar,
|
||||
courts CourtRegistry,
|
||||
) (*RuleCalculation, error) {
|
||||
triggerDate, err := time.Parse("2006-01-02", params.TriggerDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err)
|
||||
}
|
||||
|
||||
rule, pt, err := resolveRule(ctx, params, catalog)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mandWire, _ := wireFlagsFromPriority(rule.Priority)
|
||||
out := &RuleCalculation{
|
||||
Rule: RuleCalculationRule{
|
||||
ID: rule.ID.String(),
|
||||
NameDE: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
DurationValue: rule.DurationValue,
|
||||
DurationUnit: rule.DurationUnit,
|
||||
IsMandatory: mandWire,
|
||||
},
|
||||
Proceeding: RuleCalculationProceeding{
|
||||
Code: pt.Code,
|
||||
NameDE: pt.Name,
|
||||
NameEN: pt.NameEN,
|
||||
},
|
||||
TriggerDate: params.TriggerDate,
|
||||
}
|
||||
if rule.SubmissionCode != nil {
|
||||
out.Rule.LocalCode = *rule.SubmissionCode
|
||||
}
|
||||
if rule.RuleCode != nil {
|
||||
out.Rule.RuleRef = *rule.RuleCode
|
||||
}
|
||||
if rule.LegalSource != nil {
|
||||
out.Rule.LegalSource = *rule.LegalSource
|
||||
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
|
||||
out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource)
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
out.Rule.Party = *rule.PrimaryParty
|
||||
}
|
||||
if rule.DeadlineNotes != nil {
|
||||
out.Rule.NotesDE = *rule.DeadlineNotes
|
||||
}
|
||||
if rule.DeadlineNotesEn != nil {
|
||||
out.Rule.NotesEN = *rule.DeadlineNotesEn
|
||||
}
|
||||
// Slice 9 (t-paliad-195) replacement for the dropped condition_flag
|
||||
// text[] enumeration: walk the jsonb gate to pull out flag-leaf
|
||||
// names. Returns nil on an unconditional rule.
|
||||
out.FlagsRequired = ExtractFlagsFromExpr(rule.ConditionExpr)
|
||||
|
||||
// Court-determined: no calculable date.
|
||||
if rule.IsCourtSet {
|
||||
out.IsCourtSet = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Resolve flag-conditional duration via the unified condition_expr
|
||||
// evaluator.
|
||||
flagSet := make(map[string]struct{}, len(params.Flags))
|
||||
for _, f := range params.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
durationValue := rule.DurationValue
|
||||
durationUnit := rule.DurationUnit
|
||||
gateMet := EvalConditionExpr([]byte(rule.ConditionExpr), flagSet)
|
||||
if gateMet && HasConditionExpr(rule.ConditionExpr) {
|
||||
out.FlagsApplied = out.FlagsRequired
|
||||
if rule.AltDurationValue != nil {
|
||||
durationValue = *rule.AltDurationValue
|
||||
}
|
||||
if rule.AltDurationUnit != nil {
|
||||
durationUnit = *rule.AltDurationUnit
|
||||
}
|
||||
if rule.AltRuleCode != nil {
|
||||
out.Rule.RuleRef = *rule.AltRuleCode
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-duration non-court-determined rules are "filed at the same
|
||||
// time as parent" markers: effectively mean "due on the trigger
|
||||
// date itself".
|
||||
if durationValue == 0 {
|
||||
out.OriginalDate = params.TriggerDate
|
||||
out.DueDate = params.TriggerDate
|
||||
return out, nil
|
||||
}
|
||||
|
||||
defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
|
||||
country, regime, err := courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err)
|
||||
}
|
||||
|
||||
timing := ""
|
||||
if rule.Timing != nil {
|
||||
timing = *rule.Timing
|
||||
}
|
||||
endDate, adjusted, wasAdj, reason := ApplyDuration(
|
||||
triggerDate, durationValue, durationUnit, timing, country, regime, holidays,
|
||||
)
|
||||
out.OriginalDate = endDate.Format("2006-01-02")
|
||||
out.DueDate = adjusted.Format("2006-01-02")
|
||||
out.WasAdjusted = wasAdj
|
||||
out.AdjustmentReason = reason
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveRule resolves CalcRuleParams to a rule + its proceeding type.
|
||||
// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The
|
||||
// frontend uses the latter form (it has the pill context) and the
|
||||
// programmatic / test caller can use the former.
|
||||
func resolveRule(ctx context.Context, params CalcRuleParams, catalog Catalog) (*Rule, *ProceedingType, error) {
|
||||
if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
|
||||
return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
|
||||
}
|
||||
|
||||
if params.RuleID != "" {
|
||||
rule, err := catalog.LoadRuleByID(ctx, params.RuleID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if rule.ProceedingTypeID == nil {
|
||||
return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
|
||||
}
|
||||
pt, err := catalog.LoadProceedingByID(ctx, *rule.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
|
||||
}
|
||||
return rule, pt, nil
|
||||
}
|
||||
|
||||
rule, pt, err := catalog.LoadRuleByCode(ctx, params.ProceedingCode, params.RuleLocalCode)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return rule, pt, nil
|
||||
}
|
||||
|
||||
// ApplyRuleOverrides replaces rules whose ID appears in `overrides`
|
||||
// with the override row, and appends any override whose ID isn't in
|
||||
// the source list (net-new drafts the rule editor wants to preview).
|
||||
//
|
||||
// Used by the Slice 11a (t-paliad-191) preview endpoint: the editor
|
||||
// passes the draft as an override so Calculate runs against the
|
||||
// proposed shape without writing to the DB. Empty overrides slice =
|
||||
// pass-through.
|
||||
func ApplyRuleOverrides(src, overrides []Rule) []Rule {
|
||||
if len(overrides) == 0 {
|
||||
return src
|
||||
}
|
||||
byID := make(map[uuid.UUID]Rule, len(overrides))
|
||||
for _, o := range overrides {
|
||||
byID[o.ID] = o
|
||||
}
|
||||
out := make([]Rule, 0, len(src)+len(overrides))
|
||||
seen := make(map[uuid.UUID]bool, len(overrides))
|
||||
for _, r := range src {
|
||||
if ov, ok := byID[r.ID]; ok {
|
||||
out = append(out, ov)
|
||||
seen[ov.ID] = true
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
for _, o := range overrides {
|
||||
if seen[o.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional)
|
||||
// pair from the unified priority enum so the wire shape stays
|
||||
// pixel-identical. Mapping mirrors mig 083's backfill (per design §2.3):
|
||||
//
|
||||
// 'mandatory' → (true, false)
|
||||
// 'optional' → (true, true)
|
||||
// 'recommended' → (false, false)
|
||||
// 'informational' → (false, false)
|
||||
// (unknown) → (true, false)
|
||||
func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
||||
switch priority {
|
||||
case "mandatory":
|
||||
return true, false
|
||||
case "optional":
|
||||
return true, true
|
||||
case "recommended":
|
||||
return false, false
|
||||
case "informational":
|
||||
return false, false
|
||||
default:
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
|
||||
// AllFlagsSet is retained as a tiny utility for callers that have a
|
||||
// flat list of flag strings + a flag-set lookup. The new condition_expr
|
||||
// gate is the canonical evaluator; this helper exists for forward-
|
||||
// compat with any future caller that wants the legacy AND-over-list
|
||||
// semantic without rebuilding the jsonb.
|
||||
func AllFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
return allFlagsSet(required, set)
|
||||
}
|
||||
|
||||
// WireFlagsFromPriority is the public form of wireFlagsFromPriority so
|
||||
// the paliad-side test suite (which historically asserted the mapping
|
||||
// directly) can still test the contract.
|
||||
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
||||
return wireFlagsFromPriority(priority)
|
||||
}
|
||||
145
pkg/litigationplanner/expr.go
Normal file
145
pkg/litigationplanner/expr.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package litigationplanner
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// allFlagsSet returns true when every element of `required` is present in
|
||||
// `set`. Empty `required` returns true (no condition). Retained as the
|
||||
// fallback predicate used by EvalConditionExpr when condition_expr is
|
||||
// NULL but the legacy condition_flag text[] is set — preserves
|
||||
// transition-window behaviour for any row Slice 2 missed (it shouldn't,
|
||||
// but defensive).
|
||||
func allFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
for _, f := range required {
|
||||
if _, ok := set[f]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EvalConditionExpr returns true iff the rule's gate predicate is
|
||||
// satisfied for the caller's flag set. Drives flag-conditional rendering
|
||||
// + flag-conditional alt-swap throughout the calculator.
|
||||
//
|
||||
// Grammar (design §2.4 long form, mig 084 backfill):
|
||||
//
|
||||
// {"flag": "<name>"} — leaf: true iff <name> ∈ flags
|
||||
// {"op": "and", "args": [<n>...]} — true iff every arg evaluates true
|
||||
// {"op": "or", "args": [<n>...]} — true iff any arg evaluates true
|
||||
// {"op": "not", "args": [<one>]} — true iff the single arg is false
|
||||
//
|
||||
// NULL / empty / "null" expression → true (unconditional). Malformed
|
||||
// JSON → true (defensive: the rule still renders, the lawyer sees
|
||||
// it even if the gate is broken).
|
||||
//
|
||||
// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag
|
||||
// text[] column; the fallback that AND'd over it is gone. Any future
|
||||
// row needing array-of-flags semantics writes the equivalent
|
||||
// {"op":"and","args":[{"flag":"<a>"},...]} jsonb directly.
|
||||
func EvalConditionExpr(expr []byte, flags map[string]struct{}) bool {
|
||||
if len(expr) == 0 || string(expr) == "null" {
|
||||
return true
|
||||
}
|
||||
return EvalConditionExprNode(expr, flags)
|
||||
}
|
||||
|
||||
// EvalConditionExprNode walks one node of the condition_expr jsonb
|
||||
// tree. Recursion depth is bounded by the editor (Slice 11 caps tree
|
||||
// depth + arg count); pre-Slice-11 backfilled rows have at most a
|
||||
// 2-arg AND (mig 084).
|
||||
func EvalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
|
||||
var node struct {
|
||||
Flag string `json:"flag"`
|
||||
Op string `json:"op"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
// Malformed → unconditional. The Slice 11 editor's validation
|
||||
// will block such writes; in the live corpus today mig 084's
|
||||
// jsonb_build_object output is well-formed by construction.
|
||||
return true
|
||||
}
|
||||
if node.Flag != "" {
|
||||
_, ok := flags[node.Flag]
|
||||
return ok
|
||||
}
|
||||
switch node.Op {
|
||||
case "and":
|
||||
for _, a := range node.Args {
|
||||
if !EvalConditionExprNode(a, flags) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "or":
|
||||
for _, a := range node.Args {
|
||||
if EvalConditionExprNode(a, flags) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "not":
|
||||
if len(node.Args) != 1 {
|
||||
// Malformed NOT — fall through to unconditional rather
|
||||
// than risk suppressing a rule the lawyer expects to see.
|
||||
return true
|
||||
}
|
||||
return !EvalConditionExprNode(node.Args[0], flags)
|
||||
}
|
||||
// Unknown op (forward-compat with editor extensions): treat as
|
||||
// unconditional so the rule still renders.
|
||||
return true
|
||||
}
|
||||
|
||||
// HasConditionExpr returns true when the rule carries a non-empty,
|
||||
// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the
|
||||
// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the
|
||||
// flag-keyed alt-swap branch. Same intent: "this rule has a gate;
|
||||
// when the gate flips to met, swap to alt".
|
||||
func HasConditionExpr(expr NullableJSON) bool {
|
||||
if len(expr) == 0 {
|
||||
return false
|
||||
}
|
||||
s := string(expr)
|
||||
return s != "null" && s != "{}"
|
||||
}
|
||||
|
||||
// ExtractFlagsFromExpr walks the jsonb gate and returns the unique
|
||||
// flag names referenced as {"flag":"<name>"} leaves. Used by
|
||||
// CalculateRule's response (FlagsRequired) so the result-card calc
|
||||
// panel can render flag checkboxes for each gate input. Replaces the
|
||||
// dropped condition_flag text[] enumeration. Returns nil on a NULL
|
||||
// expression or one that contains no flag leaves.
|
||||
func ExtractFlagsFromExpr(expr NullableJSON) []string {
|
||||
if !HasConditionExpr(expr) {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
walkFlagLeaves([]byte(expr), seen)
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for f := range seen {
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func walkFlagLeaves(raw []byte, into map[string]struct{}) {
|
||||
var node struct {
|
||||
Flag string `json:"flag"`
|
||||
Op string `json:"op"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
return
|
||||
}
|
||||
if node.Flag != "" {
|
||||
into[node.Flag] = struct{}{}
|
||||
return
|
||||
}
|
||||
for _, a := range node.Args {
|
||||
walkFlagLeaves(a, into)
|
||||
}
|
||||
}
|
||||
25
pkg/litigationplanner/holidays.go
Normal file
25
pkg/litigationplanner/holidays.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package litigationplanner
|
||||
|
||||
import "time"
|
||||
|
||||
// HolidayCalendar adjusts dates onto working days for a given
|
||||
// (country, regime) pair. The calculator only needs three primitives:
|
||||
//
|
||||
// - IsNonWorkingDay — used by the addWorkingDays walker
|
||||
// - AdjustForNonWorkingDays — forward snap (timing='after')
|
||||
// - AdjustForNonWorkingDaysBackward — backward snap (timing='before')
|
||||
// - AdjustForNonWorkingDaysWithReason — like the forward snap but
|
||||
// also returns *AdjustmentReason so the timeline can render the
|
||||
// "rolled past holiday X" footer in TimelineEntry.AdjustmentReason.
|
||||
//
|
||||
// Implementations:
|
||||
// - paliad: reads paliad.holidays, caches per-year, merges DE
|
||||
// federal fallback.
|
||||
// - embedded/upc (Slice C): in-memory year-keyed map populated from
|
||||
// the embedded JSON snapshot.
|
||||
type HolidayCalendar interface {
|
||||
IsNonWorkingDay(date time.Time, country, regime string) bool
|
||||
AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool)
|
||||
AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool)
|
||||
AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *AdjustmentReason)
|
||||
}
|
||||
123
pkg/litigationplanner/legal_source.go
Normal file
123
pkg/litigationplanner/legal_source.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package litigationplanner
|
||||
|
||||
import "strings"
|
||||
|
||||
// FormatLegalSourceDisplay renders a structured legal_source code into
|
||||
// the form HLC users read in pleadings:
|
||||
//
|
||||
// UPC.RoP.23.1 → "UPC RoP R.23(1)"
|
||||
// UPC.RoP.139 → "UPC RoP R.139"
|
||||
// DE.PatG.82.1 → "PatG §82(1)"
|
||||
// DE.ZPO.276.1 → "ZPO §276(1)"
|
||||
// EU.EPÜ.108 → "EPÜ Art.108"
|
||||
// EU.EPC-R.79.1 → "EPC R.79(1)"
|
||||
// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
|
||||
//
|
||||
// Returns the empty string for an empty input. Unknown jurisdictions
|
||||
// fall through with the structured form preserved (caller decides
|
||||
// whether to display).
|
||||
func FormatLegalSourceDisplay(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
// Malformed — return as-is so the caller still has something.
|
||||
return src
|
||||
}
|
||||
code := parts[1]
|
||||
rest := parts[2:]
|
||||
var prefix string
|
||||
switch code {
|
||||
case "RoP":
|
||||
prefix = "UPC RoP R."
|
||||
case "PatG":
|
||||
prefix = "PatG §"
|
||||
case "ZPO":
|
||||
prefix = "ZPO §"
|
||||
case "EPÜ":
|
||||
prefix = "EPÜ Art."
|
||||
case "EPC-R":
|
||||
prefix = "EPC R."
|
||||
case "RPBA":
|
||||
prefix = "RPBA Art."
|
||||
default:
|
||||
prefix = code + " "
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(prefix) + len(src))
|
||||
b.WriteString(prefix)
|
||||
b.WriteString(rest[0])
|
||||
for _, p := range rest[1:] {
|
||||
b.WriteByte('(')
|
||||
b.WriteString(p)
|
||||
b.WriteByte(')')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BuildLegalSourceURL maps a structured legal_source code to a
|
||||
// youpc.org/laws permalink when the cited body is hosted there. Today
|
||||
// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national
|
||||
// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc
|
||||
// home yet, so the helper returns the empty string for those and the
|
||||
// caller renders the display string as plain text.
|
||||
//
|
||||
// Inputs mirror FormatLegalSourceDisplay — structured dot-separated
|
||||
// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond
|
||||
// the law-number position are dropped; youpc resolves the page at
|
||||
// <type>.<number> granularity. The law-number is zero-padded to 3
|
||||
// digits to match how youpc stores law_number (laws-data.json carries
|
||||
// "001" / "023" / "220" forms).
|
||||
//
|
||||
// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023
|
||||
// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220
|
||||
// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029
|
||||
// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083
|
||||
// DE.ZPO.276.1 → "" (no youpc home — render display text plain)
|
||||
func BuildLegalSourceURL(src string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
var lawType string
|
||||
switch parts[0] + "." + parts[1] {
|
||||
case "UPC.RoP":
|
||||
lawType = "UPCRoP"
|
||||
case "UPC.UPCA":
|
||||
lawType = "UPCA"
|
||||
case "UPC.UPCS":
|
||||
lawType = "UPCS"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
number := padLawNumber(parts[2])
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://youpc.org/laws#" + lawType + "." + number
|
||||
}
|
||||
|
||||
// padLawNumber zero-pads a pure-digit law-number segment to 3 digits.
|
||||
// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art.
|
||||
// 112a) pass through unchanged so the URL still resolves. Empty input
|
||||
// returns the empty string.
|
||||
func padLawNumber(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if len(s) >= 3 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", 3-len(s)) + s
|
||||
}
|
||||
139
pkg/litigationplanner/proceeding_mapping.go
Normal file
139
pkg/litigationplanner/proceeding_mapping.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package litigationplanner
|
||||
|
||||
// proceeding_mapping bridges the two proceeding-type vocabularies in the
|
||||
// codebase: the **litigation** conceptual category (INF / REV / APP /
|
||||
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
|
||||
// + Pipeline-A rules, and the **fristenrechner** code category
|
||||
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
|
||||
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
|
||||
// bind to fristenrechner codes directly, but the litigation→fristenrechner
|
||||
// mapping is still needed for the ~40 Pipeline-A rules that remain on
|
||||
// litigation proceedings and for any other surface that thinks in
|
||||
// litigation terms.
|
||||
//
|
||||
// The mapping table here is the single source of truth — see
|
||||
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
|
||||
// design rationale + ambiguity notes, and
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
|
||||
// lowercase dot-separated naming convention applied by mig 096
|
||||
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
|
||||
// returns ok=false so callers can degrade gracefully ("no narrowing")
|
||||
// instead of guessing.
|
||||
|
||||
// Stable code constants — the strings landed by mig 096. Use these
|
||||
// throughout the codebase so a future rename only needs to touch this
|
||||
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
|
||||
// projects.proceeding_type_id) are unaffected by the rename.
|
||||
const (
|
||||
CodeUPCInfringement = "upc.inf.cfi"
|
||||
CodeUPCRevocation = "upc.rev.cfi"
|
||||
CodeUPCCounterclaim = "upc.ccr.cfi"
|
||||
CodeUPCPreliminary = "upc.pi.cfi"
|
||||
CodeUPCDamages = "upc.dmgs.cfi"
|
||||
CodeUPCDiscovery = "upc.disc.cfi"
|
||||
CodeUPCAppealMerits = "upc.apl.merits"
|
||||
CodeUPCAppealOrder = "upc.apl.order"
|
||||
CodeUPCAppealCost = "upc.apl.cost"
|
||||
CodeDEInfringementLG = "de.inf.lg"
|
||||
CodeDEInfringementOLG = "de.inf.olg"
|
||||
CodeDEInfringementBGH = "de.inf.bgh"
|
||||
CodeDENullityBPatG = "de.null.bpatg"
|
||||
CodeDENullityBGH = "de.null.bgh"
|
||||
CodeEPAGrant = "epa.grant.exa"
|
||||
CodeEPAOpposition = "epa.opp.opd"
|
||||
CodeEPAOppositionAppeal = "epa.opp.boa"
|
||||
CodeDPMAOpposition = "dpma.opp.dpma"
|
||||
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
|
||||
CodeDPMAAppealBGH = "dpma.appeal.bgh"
|
||||
)
|
||||
|
||||
// MapLitigationToFristenrechner returns the fristenrechner code +
|
||||
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
||||
//
|
||||
// Inputs are case-sensitive — pass the canonical upper-snake form
|
||||
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
|
||||
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
|
||||
// fristenrechner code; callers should treat that as "no narrowing"
|
||||
// and leave the cascade wide-open rather than auto-pick.
|
||||
//
|
||||
// Condition flags are returned as a slice so callers can apply them
|
||||
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
|
||||
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
|
||||
// context applies.
|
||||
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
|
||||
switch litigationCode {
|
||||
case "INF":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, nil, true
|
||||
case "DE":
|
||||
return CodeDEInfringementLG, nil, true
|
||||
}
|
||||
case "REV":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCRevocation, nil, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "CCR":
|
||||
// Counterclaim revocation — UPC fold-in is structural (the
|
||||
// counterclaim lives inside an upc.inf.cfi proceeding with the
|
||||
// with_ccr flag). DE Nichtigkeit is conceptually the same
|
||||
// adversarial-validity test, no separate flag.
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return CodeUPCInfringement, []string{"with_ccr"}, true
|
||||
case "DE":
|
||||
return CodeDENullityBPatG, nil, true
|
||||
}
|
||||
case "AMD":
|
||||
// Amendment-application bundled into upc.inf.cfi via with_amend.
|
||||
// No DE / EPA / DPMA analogue today.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCInfringement, []string{"with_amend"}, true
|
||||
}
|
||||
case "APP":
|
||||
// Appeal is ambiguous in DE (OLG vs BGH) and the project
|
||||
// model doesn't carry the instance hint we'd need to
|
||||
// disambiguate. UPC is unambiguous — upc.apl.merits covers
|
||||
// the merits appeal track for inf/rev/ccr/damages.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCAppealMerits, nil, true
|
||||
}
|
||||
case "APM":
|
||||
// Preliminary injunction / urgency procedure — UPC-only
|
||||
// concept in the fristenrechner taxonomy.
|
||||
if jurisdiction == "UPC" {
|
||||
return CodeUPCPreliminary, nil, true
|
||||
}
|
||||
case "OPP":
|
||||
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
|
||||
// doesn't surface from the litigation vocabulary today.
|
||||
if jurisdiction == "EPA" {
|
||||
return CodeEPAOpposition, nil, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// ResolveCounterclaimRouting handles the determinator's
|
||||
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
|
||||
// for taxonomic completeness, but no rules are attached to it. When the
|
||||
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
|
||||
// upc.inf.cfi with a default with_ccr=true flag — see
|
||||
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
|
||||
//
|
||||
// `code` is the proceeding code the cascade resolved to. If it's
|
||||
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
|
||||
// []string{"with_ccr"}, true). For any other code the function returns
|
||||
// (code, nil, false) and callers proceed with the code unchanged. The
|
||||
// boolean signals "routing was applied"; the caller can surface the hint
|
||||
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
|
||||
// weiter." in the UI.
|
||||
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
|
||||
if route, ok := SubTrackRoutings[code]; ok {
|
||||
return route.ParentCode, route.DefaultFlags, true
|
||||
}
|
||||
return code, nil, false
|
||||
}
|
||||
151
pkg/litigationplanner/sort.go
Normal file
151
pkg/litigationplanner/sort.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SortDeadlinesByDurationWithinTriggerGroup is the public form of
|
||||
// sortDeadlinesByDurationWithinTriggerGroup. Exported so paliad's
|
||||
// test suite (which historically reached the helper directly) can
|
||||
// keep invoking it via a tiny wrapper.
|
||||
func SortDeadlinesByDurationWithinTriggerGroup(
|
||||
deadlines []TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) {
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
}
|
||||
|
||||
// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of
|
||||
// deadlines whose underlying rule shares the same trigger group
|
||||
// (parent_id + trigger_event_id) and reorders each run in place by
|
||||
// duration ascending. Different trigger groups keep their original
|
||||
// proceeding-sequence position — the walk only ever permutes adjacent
|
||||
// same-group rows.
|
||||
//
|
||||
// Sort key (within a run):
|
||||
// 1. Conditional / court-set rows (no concrete date in the duration
|
||||
// ladder) sort LAST, tiebroken by submission_code.
|
||||
// 2. duration_unit weight ASC: days/working_days < weeks < months < years
|
||||
// 3. duration_value ASC
|
||||
// 4. submission_code ASC (deterministic tiebreak)
|
||||
//
|
||||
// Issue: m/paliad#128 — post-decision optional events (R.151/R.353
|
||||
// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog
|
||||
// order instead of likely-sequence order. (t-paliad-296)
|
||||
func sortDeadlinesByDurationWithinTriggerGroup(
|
||||
deadlines []TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) {
|
||||
if len(deadlines) < 2 {
|
||||
return
|
||||
}
|
||||
n := len(deadlines)
|
||||
i := 0
|
||||
for i < n {
|
||||
gid := triggerGroupKey(deadlines[i], ruleByID)
|
||||
j := i + 1
|
||||
for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid {
|
||||
j++
|
||||
}
|
||||
// Root rules (no parent and no trigger_event) get gid="" and
|
||||
// would otherwise collapse into one big run. Skip the sort for
|
||||
// the "root" pseudo-group — each root rule represents its own
|
||||
// anchor (SoC, oral hearing, decision …) and the proceeding-
|
||||
// sequence order between them must be preserved.
|
||||
if j-i > 1 && gid != "" {
|
||||
chunk := deadlines[i:j]
|
||||
sort.SliceStable(chunk, func(a, b int) bool {
|
||||
return durationLessForSort(chunk[a], chunk[b], ruleByID)
|
||||
})
|
||||
}
|
||||
i = j
|
||||
}
|
||||
}
|
||||
|
||||
// triggerGroupKey returns a string key identifying which trigger group
|
||||
// a deadline belongs to. Same key = same group = candidates for sort.
|
||||
// Empty string means "root" (no parent, no trigger_event) — used as a
|
||||
// sentinel by the caller to skip sorting roots against each other.
|
||||
func triggerGroupKey(d TimelineEntry, ruleByID map[uuid.UUID]Rule) string {
|
||||
rid, err := uuid.Parse(d.RuleID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
r, ok := ruleByID[rid]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
return "p:" + r.ParentID.String()
|
||||
}
|
||||
if r.TriggerEventID != nil {
|
||||
return fmt.Sprintf("t:%d", *r.TriggerEventID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// durationLessForSort compares two deadlines for the duration-ascending
|
||||
// sort. Court-set / conditional rows (no concrete date) sort LAST
|
||||
// regardless of duration — they don't fit the duration ladder.
|
||||
func durationLessForSort(
|
||||
a, b TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) bool {
|
||||
aLast := a.IsCourtSet || a.IsConditional
|
||||
bLast := b.IsCourtSet || b.IsConditional
|
||||
if aLast != bLast {
|
||||
return !aLast
|
||||
}
|
||||
if aLast && bLast {
|
||||
return a.Code < b.Code
|
||||
}
|
||||
|
||||
ra := lookupRuleFromDeadline(a, ruleByID)
|
||||
rb := lookupRuleFromDeadline(b, ruleByID)
|
||||
|
||||
wa := durationUnitWeight(ra.DurationUnit)
|
||||
wb := durationUnitWeight(rb.DurationUnit)
|
||||
if wa != wb {
|
||||
return wa < wb
|
||||
}
|
||||
if ra.DurationValue != rb.DurationValue {
|
||||
return ra.DurationValue < rb.DurationValue
|
||||
}
|
||||
return a.Code < b.Code
|
||||
}
|
||||
|
||||
func lookupRuleFromDeadline(
|
||||
d TimelineEntry,
|
||||
ruleByID map[uuid.UUID]Rule,
|
||||
) Rule {
|
||||
if d.RuleID == "" {
|
||||
return Rule{}
|
||||
}
|
||||
rid, err := uuid.Parse(d.RuleID)
|
||||
if err != nil {
|
||||
return Rule{}
|
||||
}
|
||||
return ruleByID[rid]
|
||||
}
|
||||
|
||||
// durationUnitWeight maps a duration unit to its sort weight so the
|
||||
// trigger-group sort can order shorter durations first. days and
|
||||
// working_days share weight 0 (both are sub-week granularities);
|
||||
// unknown units sort to the end so they're visible as a tail rather
|
||||
// than silently winning.
|
||||
func durationUnitWeight(unit string) int {
|
||||
switch unit {
|
||||
case "days", "working_days":
|
||||
return 0
|
||||
case "weeks":
|
||||
return 1
|
||||
case "months":
|
||||
return 2
|
||||
case "years":
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
53
pkg/litigationplanner/subtrack.go
Normal file
53
pkg/litigationplanner/subtrack.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package litigationplanner
|
||||
|
||||
// SubTrackRouting describes a proceeding type that has no native rules
|
||||
// of its own and is normally rendered inside a parent proceeding's flow
|
||||
// with one or more condition flags enabled. The Procedure Roadmap
|
||||
// (verfahrensablauf) routes calc requests for these codes to the parent
|
||||
// proceeding + default flags, but preserves the user-picked code/name
|
||||
// in the response identity and surfaces a contextual note explaining
|
||||
// the framing — see m/paliad#58 and the design doc cited above.
|
||||
//
|
||||
// Adding a new sub-track is a data-only change here: extend
|
||||
// SubTrackRoutings with the (code, parent, flags, note) tuple and the
|
||||
// renderer picks it up automatically. The note copy lives in this file
|
||||
// because it's semantic to the routing, not UI chrome.
|
||||
type SubTrackRouting struct {
|
||||
// Code is the user-picked proceeding code (e.g. "upc.ccr.cfi").
|
||||
Code string
|
||||
// ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi").
|
||||
ParentCode string
|
||||
// DefaultFlags are merged into the user's flag set so the
|
||||
// gated rules render. Order is preserved.
|
||||
DefaultFlags []string
|
||||
// NoteDE / NoteEN are the contextual banner above the timeline,
|
||||
// explaining that the proceeding type is normally a sub-track.
|
||||
// Plain text — the frontend renders them as a banner.
|
||||
NoteDE string
|
||||
NoteEN string
|
||||
}
|
||||
|
||||
// SubTrackRoutings — single-source-of-truth registry. Today: just CCR.
|
||||
// The pattern generalises to other "sub-track" proceeding types (e.g.
|
||||
// R.30 application to amend the patent as a standalone roadmap, R.46
|
||||
// preliminary objection) once they have a proceeding-type code of their
|
||||
// own. New entries here are picked up by the spawn-as-standalone
|
||||
// renderer in Calculate without further wiring.
|
||||
var SubTrackRoutings = map[string]SubTrackRouting{
|
||||
CodeUPCCounterclaim: {
|
||||
Code: CodeUPCCounterclaim,
|
||||
ParentCode: CodeUPCInfringement,
|
||||
DefaultFlags: []string{"with_ccr"},
|
||||
NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.",
|
||||
NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.",
|
||||
},
|
||||
}
|
||||
|
||||
// LookupSubTrackRouting returns the sub-track routing for a proceeding
|
||||
// code, or (zero, false) if the code is not a sub-track. Used by the
|
||||
// fristenrechner Calculate path to spawn the parent flow with the sub-
|
||||
// track's default flags.
|
||||
func LookupSubTrackRouting(code string) (SubTrackRouting, bool) {
|
||||
r, ok := SubTrackRoutings[code]
|
||||
return r, ok
|
||||
}
|
||||
428
pkg/litigationplanner/types.go
Normal file
428
pkg/litigationplanner/types.go
Normal file
@@ -0,0 +1,428 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// NullableJSON is a jsonb column that may be NULL. json.RawMessage
|
||||
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
|
||||
// from Postgres breaks the row scan with "unsupported Scan, storing
|
||||
// driver.Value type <nil> into type *json.RawMessage" — exactly the
|
||||
// error that hid every approval_request from the inbox when m's first
|
||||
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
|
||||
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
|
||||
// fixes the scan and preserves inline JSON output (no base64 cast).
|
||||
type NullableJSON []byte
|
||||
|
||||
// Scan implements sql.Scanner.
|
||||
func (n *NullableJSON) Scan(value any) error {
|
||||
if value == nil {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
*n = append((*n)[:0], v...)
|
||||
return nil
|
||||
case string:
|
||||
*n = []byte(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer.
|
||||
func (n NullableJSON) Value() (driver.Value, error) {
|
||||
if len(n) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
// MarshalJSON emits the raw JSON bytes (or "null").
|
||||
func (n NullableJSON) MarshalJSON() ([]byte, error) {
|
||||
if len(n) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return []byte(n), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON consumes raw JSON bytes (literal "null" maps to nil).
|
||||
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*n = nil
|
||||
return nil
|
||||
}
|
||||
*n = append((*n)[:0], data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rule is one rule in the proceeding-rule tree (UPC R.023, etc.).
|
||||
//
|
||||
// JSON + db tags are intentionally identical to the historical
|
||||
// paliad.deadline_rules row shape — sqlx scans onto Rule directly and
|
||||
// the wire bytes the frontend reads are unchanged from the pre-extract
|
||||
// shape.
|
||||
type Rule struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||
SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
|
||||
// this rule's concept (joined via paliad.deadline_concept_event_types
|
||||
// where is_default = true). Lets the deadline create form auto-populate
|
||||
// the Typ chip when the user picks this rule. Hydrated by the service
|
||||
// layer; not a column. NULL when the concept has no mapped event_type.
|
||||
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
|
||||
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression. Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional.
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic (primary_party='court'
|
||||
// OR event_type IN ('hearing','decision','order')).
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow:
|
||||
// 'draft' | 'published' | 'archived'.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
|
||||
// ChoicesOffered declares which per-event-card choice-kinds this
|
||||
// rule offers on the Verfahrensablauf timeline (mig 129,
|
||||
// t-paliad-265). NULL = no caret affordance (default).
|
||||
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR
|
||||
// /APM/APP/AMD/ZPO_CIVIL — matter management) or the lowercase dot-
|
||||
// separated fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) —
|
||||
// see docs/design-proceeding-code-taxonomy-2026-05-18.md.
|
||||
type ProceedingType struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||
Category *string `db:"category" json:"category,omitempty"`
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
}
|
||||
|
||||
// AdjustmentReason describes why a date was rolled forward / backward
|
||||
// off a non-working day. Populated by HolidayCalendar implementations
|
||||
// when AdjustForNonWorkingDaysWithReason moves the date.
|
||||
//
|
||||
// Date fields are JSON-serialised as YYYY-MM-DD strings (matching
|
||||
// TimelineEntry.DueDate / OriginalDate) so the frontend doesn't need a
|
||||
// separate RFC3339 parser.
|
||||
type AdjustmentReason struct {
|
||||
// Kind is the dominant cause; longest cause wins when several apply
|
||||
// (vacation > public_holiday > weekend).
|
||||
Kind string `json:"kind"`
|
||||
// Holidays collects every named holiday encountered while walking
|
||||
// past the non-working run, deduped by (date, name). May be empty
|
||||
// when the only cause is a weekend.
|
||||
Holidays []HolidayDTO `json:"holidays,omitempty"`
|
||||
// VacationName, VacationStart and VacationEnd describe the
|
||||
// contiguous vacation block the original date sits in. Populated
|
||||
// only when Kind == "vacation". Span boundaries are the first/last
|
||||
// vacation day in the block (excludes the weekends that pad it).
|
||||
VacationName string `json:"vacationName,omitempty"`
|
||||
VacationStart string `json:"vacationStart,omitempty"`
|
||||
VacationEnd string `json:"vacationEnd,omitempty"`
|
||||
// OriginalWeekday is the English weekday name of the original date —
|
||||
// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
|
||||
// can localise it.
|
||||
OriginalWeekday string `json:"originalWeekday,omitempty"`
|
||||
}
|
||||
|
||||
// HolidayDTO is the JSON shape for a holiday emitted in
|
||||
// AdjustmentReason — distinct from a DB-level Holiday row so dates
|
||||
// serialise as YYYY-MM-DD strings.
|
||||
type HolidayDTO struct {
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
IsVacation bool `json:"isVacation,omitempty"`
|
||||
IsClosure bool `json:"isClosure,omitempty"`
|
||||
}
|
||||
|
||||
// CalcOptions carries optional inputs for Calculate. Callers can leave
|
||||
// fields empty/nil for the legacy behaviour.
|
||||
//
|
||||
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with
|
||||
// anchor_alt='priority_date' (e.g. epa.grant.exa.ep_grant.publish
|
||||
// per Art. 93 EPÜ) use this date as their base instead of the
|
||||
// parent's adjusted date / the trigger date.
|
||||
// - Flags: lowercase string flags from the UI (e.g. "with_ccr",
|
||||
// "with_amend"). Drive condition_expr evaluation + flag-keyed
|
||||
// alt-swap.
|
||||
// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides
|
||||
// of the computed deadline date. When a child rule chains off a
|
||||
// parent whose code is in AnchorOverrides, the override date is
|
||||
// used as the anchor instead of the parent's calculated date.
|
||||
// - CourtID picks the forum the proceeding is filed in (e.g.
|
||||
// "upc-ld-paris", "de-bgh"). The calculator resolves it to
|
||||
// (country, regime) for non-working-day computation.
|
||||
// - TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
|
||||
// rules: when non-nil, the proceedingCode argument is ignored and
|
||||
// the engine selects rules WHERE trigger_event_id = *filter.
|
||||
// - RuleOverrides substitutes specific rules in the calculator's
|
||||
// rule list with caller-supplied in-memory rows. Used by the
|
||||
// rule-editor preview.
|
||||
// - PerCardAppellant / SkipRules / IncludeCCRFor / IncludeHidden
|
||||
// drive per-event-card choice overlays (t-paliad-265, t-paliad-290).
|
||||
// - ProjectHint scopes the catalog lookup to a project context
|
||||
// (paliad's catalog uses this to merge in project-scoped rules
|
||||
// in future slices; v1 catalogs may ignore it).
|
||||
type CalcOptions struct {
|
||||
PriorityDateStr string
|
||||
Flags []string
|
||||
AnchorOverrides map[string]string
|
||||
CourtID string
|
||||
TriggerEventIDFilter *int64
|
||||
RuleOverrides []Rule
|
||||
|
||||
PerCardAppellant map[string]string
|
||||
SkipRules map[string]struct{}
|
||||
IncludeCCRFor map[string]struct{}
|
||||
IncludeHidden bool
|
||||
|
||||
ProjectHint ProjectHint
|
||||
}
|
||||
|
||||
// ProjectHint scopes a Catalog call to a specific project. Paliad's
|
||||
// catalog uses ProjectID to merge in project-scoped rules in a future
|
||||
// slice (m/paliad#124 §6 — currently dropped per m's 2026-05-26
|
||||
// decision; the field stays for forward-compat). Other catalogs (the
|
||||
// embedded UPC snapshot used by youpc.org) ignore the hint.
|
||||
//
|
||||
// Zero value = no project context (the abstract Verfahrensablauf /
|
||||
// public Fristenrechner case).
|
||||
type ProjectHint struct {
|
||||
ProjectID uuid.UUID
|
||||
}
|
||||
|
||||
// CalcRuleParams identifies a single rule and the inputs needed to
|
||||
// compute one deadline from it. Caller supplies either RuleID OR the
|
||||
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
|
||||
// hand from the concept-card pill it just received a click on.
|
||||
type CalcRuleParams struct {
|
||||
RuleID string // optional — UUID
|
||||
ProceedingCode string // optional — used with RuleLocalCode
|
||||
RuleLocalCode string // optional — paliad.deadline_rules.submission_code
|
||||
TriggerDate string // required — YYYY-MM-DD
|
||||
Flags []string // optional — condition_flag inputs
|
||||
CourtID string // optional — selects holiday calendar
|
||||
}
|
||||
|
||||
// Timeline is the package's structured return for Calculate. JSON tags
|
||||
// are aligned with paliad's historical UIResponse so handlers can serve
|
||||
// it directly — the wire bytes the frontend reads are unchanged.
|
||||
type Timeline struct {
|
||||
ProceedingType string `json:"proceedingType"`
|
||||
ProceedingName string `json:"proceedingName"`
|
||||
ProceedingNameEN string `json:"proceedingNameEN,omitempty"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
Deadlines []TimelineEntry `json:"deadlines"`
|
||||
ContextualNote string `json:"contextualNote,omitempty"`
|
||||
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
HiddenCount int `json:"hiddenCount"`
|
||||
}
|
||||
|
||||
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
|
||||
// interface (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
||||
type TimelineEntry struct {
|
||||
RuleID string `json:"ruleId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
Party string `json:"party"`
|
||||
Priority string `json:"priority"`
|
||||
RuleRef string `json:"ruleRef"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NotesEN string `json:"notesEN,omitempty"`
|
||||
DueDate string `json:"dueDate"`
|
||||
OriginalDate string `json:"originalDate"`
|
||||
WasAdjusted bool `json:"wasAdjusted"`
|
||||
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
||||
IsRootEvent bool `json:"isRootEvent"`
|
||||
IsCourtSet bool `json:"isCourtSet"`
|
||||
ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"`
|
||||
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
|
||||
// IsConditional signals the rule's anchor is uncertain — no
|
||||
// concrete date can be projected. Set when the rule depends on:
|
||||
// - a court-set ancestor whose date isn't anchored (overlaps
|
||||
// with IsCourtSetIndirect; the two are kept distinct because
|
||||
// IsCourtSet wraps a specific UX message "wird vom Gericht
|
||||
// bestimmt", whereas IsConditional is the broader "render as
|
||||
// 'abhängig von <parent>'" signal)
|
||||
// - timing='before' rules whose forward anchor isn't set
|
||||
// - optional opposing-side rules whose true triggering event
|
||||
// hasn't been recorded for this project (e.g. R.262(2)
|
||||
// Erwiderung auf Vertraulichkeitsantrag)
|
||||
// When true, DueDate and OriginalDate are empty and the frontend
|
||||
// renders an "abhängig von <ParentRuleName>" chip in place of a
|
||||
// date. Suppressed by an explicit user anchor. (t-paliad-289)
|
||||
IsConditional bool `json:"isConditional,omitempty"`
|
||||
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
|
||||
// parent's identity so the frontend can render
|
||||
// "abhängig von <ParentRuleName>" when IsConditional=true.
|
||||
// Populated whenever the rule has a parent_id, not only when
|
||||
// conditional — keeps the wire shape stable. Empty for root rules.
|
||||
// When a rule has a real trigger_event_id, these fields are
|
||||
// overridden to point at the trigger_events catalog row instead of
|
||||
// the parent_id chain (t-paliad-294 / m/paliad#126).
|
||||
ParentRuleCode string `json:"parentRuleCode,omitempty"`
|
||||
ParentRuleName string `json:"parentRuleName,omitempty"`
|
||||
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
|
||||
IsOverridden bool `json:"isOverridden,omitempty"`
|
||||
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
|
||||
AppellantContext string `json:"appellantContext,omitempty"`
|
||||
IsHidden bool `json:"isHidden,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculation is the single-rule calc response that backs the
|
||||
// result-card click → calc-panel flow. Distinct from TimelineEntry
|
||||
// (which represents one rendered row inside a full-proceeding
|
||||
// response): RuleCalculation is self-contained.
|
||||
type RuleCalculation struct {
|
||||
Rule RuleCalculationRule `json:"rule"`
|
||||
Proceeding RuleCalculationProceeding `json:"proceeding"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
OriginalDate string `json:"originalDate"`
|
||||
DueDate string `json:"dueDate"`
|
||||
WasAdjusted bool `json:"wasAdjusted"`
|
||||
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
||||
IsCourtSet bool `json:"isCourtSet"`
|
||||
FlagsApplied []string `json:"flagsApplied,omitempty"`
|
||||
FlagsRequired []string `json:"flagsRequired,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculationRule mirrors the small subset of Rule the
|
||||
// frontend needs to render the calc panel.
|
||||
type RuleCalculationRule struct {
|
||||
ID string `json:"id"`
|
||||
LocalCode string `json:"localCode,omitempty"`
|
||||
NameDE string `json:"nameDE"`
|
||||
NameEN string `json:"nameEN"`
|
||||
RuleRef string `json:"ruleRef,omitempty"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
LegalSourceURL string `json:"legalSourceURL,omitempty"`
|
||||
DurationValue int `json:"durationValue"`
|
||||
DurationUnit string `json:"durationUnit"`
|
||||
Party string `json:"party,omitempty"`
|
||||
IsMandatory bool `json:"isMandatory"`
|
||||
NotesDE string `json:"notesDE,omitempty"`
|
||||
NotesEN string `json:"notesEN,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculationProceeding identifies the proceeding context for the
|
||||
// rule. Used by the frontend for display + by the add-to-project flow.
|
||||
type RuleCalculationProceeding struct {
|
||||
Code string `json:"code"`
|
||||
NameDE string `json:"nameDE"`
|
||||
NameEN string `json:"nameEN"`
|
||||
}
|
||||
|
||||
// FristenrechnerType mirrors the /api/tools/proceeding-types response
|
||||
// metadata.
|
||||
type FristenrechnerType struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"nameEN"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// TriggerEvent is a UPC procedural event referenced by deadline rules
|
||||
// whose semantic anchor is an event rather than a parent rule (the
|
||||
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
|
||||
// triggered by the opposing party's confidentiality application, not
|
||||
// by the SoC parent rule). The conditional-rendering branch reads
|
||||
// this when stamping ParentRule* on the wire.
|
||||
type TriggerEvent struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameDE string `db:"name_de" json:"name_de"`
|
||||
Description string `db:"description" json:"description"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// Sentinel errors surfaced by Calculate / CalculateRule / Catalog
|
||||
// implementations. Handlers map these to HTTP statuses.
|
||||
var (
|
||||
ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
||||
ErrUnknownRule = errors.New("unknown rule")
|
||||
)
|
||||
Reference in New Issue
Block a user