Compare commits

...

48 Commits

Author SHA1 Message Date
mAi
a2e1d023e4 docs(t-paliad-207): close-out assessment — verdict (A) DONE
Read-only audit of the t-paliad-207 surface per paliadin's 2026-05-20
re-engage instruction. Six commits shipped under this task are now
merged. Two larger follow-ups (m/paliad#39 youpc-laws ingest + #41 DE
combined timeline) are filed with concrete scope. Remaining tail is
optional polish, best handled as discrete issues rather than a parked
inventor.
2026-05-20 10:46:07 +02:00
mAi
faaa98cbce feat(t-paliad-207): notes toggle — compact ⓘ hover by default, expand inline when "Hinweise anzeigen" is checked
m's ask 2026-05-18 18:21: per-rule descriptive notes ("Innerhalb von 1
Monat ab Zustellung der Klage. Drei mögliche Gründe…") are noisy in the
default timeline view. Make them optional — small ⓘ icon next to the
meta line by default with full text on hover; switch in the toggle bar
expands them inline when the user wants the wall of text.

**Renderer (verfahrensablauf-core.ts)** — `CardOpts.showNotes?: boolean`
gates two render paths:
- on  → `<div class="timeline-notes">…</div>` (today's behaviour)
- off → `<span class="timeline-note-hint" tabindex=0 role=note
        aria-label=… title=…>ⓘ</span>` inside the meta line (browser
        title for hover, aria-label for screen readers, tabindex for
        keyboard accessibility)

Pass-through wired in renderColumnsBody too so the columns view picks
up the toggle equally.

**Toggle UI** — added a checkbox row to the existing `fristen-view-toggle`
bar on both /tools/verfahrensablauf and /tools/fristenrechner:
"Hinweise anzeigen" / "Show details". CSS modifier
`.fristen-notes-option` separates it from the radio view-picker with
a leading border-left.

**State** — `paliad.fristen.notes-show` localStorage key (shared
between both pages so the preference carries across), default off,
re-render on flip.

i18n: 1 new key DE + EN (deadlines.notes.show). Build clean.
2026-05-18 18:18:19 +02:00
mAi
1e7f2ee5c3 feat(t-paliad-207): mig 102 — track-aware sequence reshuffle for upc.inf.cfi (infringement → revocation → amendment)
m's ask 2026-05-18 18:08: 'the infringement parts (like Replik) should
show above the part for the revocation (Erwiderung Nichtigkeitswider-
klage)'. Three tracks (infringement / revocation / amendment) coexist
on upc.inf.cfi once with_ccr / with_amend are set. They share tied
calendar dates because R.29/R.30/R.32 all key off the SoD or its
descendants. Current sequence_orders (post-mig 100) interleave them
arbitrarily; user sees Erwiderung-zur-CCR before Replik even though
Replik is the infringement-side response to the same triggering event.

**Re-sequencing** keeps the existing soc=0, prelim=5, sod=10 head and
the interim=40 / oral=50 / decision=60 / cost_app=70 / appeal_spawn=80
tail untouched. The 10 reshuffled rules move into a track-aware
arrangement:

  10-19 infringement: sod=10, reply=12, rejoin=14
  20-29 revocation:   ccr=20, def_to_ccr=22, reply_def_ccr=24, rejoin_reply_ccr=26
  30-39 amendment:    app_to_amend=30, def_to_amend=32, reply_def_amd=34, rejoin_amd=36

Tied-date ordering after the reshuffle:
  D+3mo: sod(10), ccr(20)                            — SoD then its CCR
  D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
  D+7mo: reply_def_ccr(24), def_to_amend(32)         — rev → amd
  D+8mo: rejoin_reply_ccr(26), reply_def_amd(34)     — rev → amd

**Two-phase swap** — every reshuffled rule first parks at sequence
1000+number, then jumps to its final value. Prevents transient
sequence-collisions if Postgres evaluates UPDATEs in parallel within
the same statement. Each UPDATE is keyed by submission_code AND the
SOURCE sequence_order, so re-apply is a no-op.

audit_reason set_config at top per mig 099 hotfix pattern. Counter
re-checked.
2026-05-18 18:13:40 +02:00
mAi
cbf4fe6d93 fix(t-paliad-207): mig 101 — strip rule cite from Einspruch names + flip CCR priority informational→optional
Two corrections to mig 100's merged-state:

1. **CCR priority informational → optional**. m's correction
   2026-05-18 18:01. The fermi amend (e8d658a) flipping this didn't
   land — paliadin merged the pre-amend c10f8cf. The Nichtigkeits-
   widerklage is a substantive defensive choice, rendered unchecked
   in the save modal so user opts in if they want to track it.

2. **Strip rule-cite brackets from Einspruch names**. m's
   correction 2026-05-18 18:08. Every other rule name in the corpus
   carries the act-name without a parenthetical rule cite — the two
   Einspruch rules were outliers:
     upc.inf.cfi.prelim  'Einspruch (R. 19 VerfO)'             → 'Einspruch'
     upc.rev.cfi.prelim  'Einspruch (R. 19 i.V.m. R. 46 VerfO)' → 'Einspruch'
   plus EN equivalents. The legal_source / rule_code columns already
   carry the citation in the meta line, so the name stays clean.

Idempotent: priority UPDATE guarded on 'informational'; name UPDATEs
guarded on the current parenthetical-bearing values. audit_reason
set_config at top per mig 099 hotfix pattern. Counter re-checked
(mig 100 just merged; next is 101).
2026-05-18 18:12:16 +02:00
mAi
4ddcd28d26 Merge: t-paliad-207 — mig 100 (upc.inf.cfi.ccr informational rule, makes CCR filing visible on timeline when with_ccr is set) 2026-05-18 17:46:55 +02:00
mAi
c10f8cff70 feat(t-paliad-207): mig 100 — make CCR filing visible in calc output when with_ccr is set
m's observation 2026-05-18 (interactive session): toggling "Mit Nichtig-
keitswiderklage" surfaces the response rules (def_to_ccr, reply, rejoin,
…) but the triggering event itself — the act of filing the CCR — is
invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement of
Defence with the same 3-month deadline, so the corpus author (mig 028)
skipped it. UX problem: users see consequences without the cause.

**New rule** `upc.inf.cfi.ccr`:
- parent: `upc.inf.cfi.soc` (root anchor, same as SoD)
- duration: 3 months (same as SoD — no separate deadline)
- party: defendant
- legal_source: `UPC.RoP.25.1`
- condition_expr: `{"flag":"with_ccr"}`
- priority: **`informational`** — renders as a notice card, no save
  action, no duplicate write into paliad.deadlines (the SoD's row
  already covers the calendar date).

**Sequence reshuffle** — inserting at sequence_order=11 pushes
def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
SoD → CCR → def_to_ccr → app_to_amend (cause before effect).

**Idempotency** — INSERT uses NOT EXISTS keyed on
(proceeding_type_id, submission_code, lifecycle_state='published');
UPDATEs are guarded by the source sequence_order so re-apply is a
no-op. audit_reason set via set_config('paliad.audit_reason', ...,
true) at the top per the mig 099 hotfix pattern.

Migration counter re-checked against origin/main + ls
internal/db/migrations/ | tail before picking 100 — per the friction
note from msg 2016.

Build hygiene: go build/vet clean; bun run build clean (no i18n
changes). Down.sql restores both sequence values + DELETEs the new
row. Branch: mai/fermi/interactive-session.
2026-05-18 17:46:08 +02:00
mAi
5ae1e5ad01 Merge: t-paliad-211 — Custom Views polish (calendar week/day + click-drill + aligned grid, timeline zoom + lane-label clamping, filter-bar transfer) 2026-05-18 17:45:44 +02:00
mAi
06c826a818 feat(t-paliad-211): mount filter-bar on Custom Views runner
The /views/{slug} runner now mounts the same FilterBar primitive that
/events and /inbox use. The saved view's filter_spec becomes the bar's
baseline, axes are picked client-side per the view's data sources so a
deadline-only view exposes deadline_status, an approval-driven view
exposes approval_viewer_role + approval_status + approval_entity_type,
etc. Universal axes (time, personal_only, sort) always render.

Per-session tweaks overlay the saved baseline without mutating the
stored row; the URL round-trips state through the bar's existing codec
so deep-links share the active narrow. "Speichern als Sicht" stays
available on user-owned views so a tweaked narrow can be forked into a
new saved view.

Shape axis is intentionally excluded from the bar — the existing
top-of-page shape chip cluster (list / cards / calendar / timeline)
already plays that role and switching now mutates the cached render
spec without re-hitting the substrate.

Empty-state hint reuses the saved filter summary as before; the bar's
onResult handler hides all shape hosts when the rows array is empty.
2026-05-18 17:45:30 +02:00
mAi
8020cb2ddb feat(t-paliad-211): timeline shape adds zoom toolbar and clamped lane labels
shape-timeline-cv now wraps the chart host with a toolbar carrying
+/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as
?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so
saved views still control the initial zoom but per-session navigation is
deep-linkable.

shape-timeline-chart paints lane labels inside a foreignObject containing
an HTML <div> with overflow:hidden + text-overflow:ellipsis + a title
attribute carrying the full text. Long project names no longer bleed
across the chart canvas; hover reveals the full label.

i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN).
2026-05-18 17:45:30 +02:00
mAi
a5b94739b4 feat(t-paliad-211): calendar shape adds week + day views and aligned grid
shape-calendar now renders month, week, and day views with a chip switcher
above the grid. Active view + anchor date persist in the URL as
?cal_view=month|week|day&cal_date=YYYY-MM-DD so per-view navigation is
deep-linkable.

Month view: weekday header row now lives inside the same CSS grid as the
day cells (one shared grid-template-columns: repeat(7,1fr)), so day labels
no longer drift relative to the columns below. Day-number is a button
that switches to day view scoped to that date; +N more pill also drills
to day view. Individual row pills route to /deadlines/{id} /
/appointments/{id} via inner anchors with click stopPropagation so they
don't trigger the day-drill.

Week view: 7 columns, full row list per column (no 3-row cap), per-column
vertical scroll for busy days.

Day view: single chronological list. Prev/next-day nav reuses the same
toolbar; week/day views also expose a "Zurück zum Monat" link.

i18n: cal.view.month|week|day + per-view prev/next labels +
cal.day.back_to_month + cal.day.open_day + cal.day.no_entries (DE+EN).
2026-05-18 17:45:30 +02:00
mAi
283c9e8f67 fix(mig 099): add missing audit_reason wrapper
Mig 099 (drop_with_po_flag) crash-looped paliad.de prod immediately
after deploy: the mig 079 trigger on paliad.deadline_rules raises
EXCEPTION 'audit reason required' on UPDATE when paliad.audit_reason
is unset. Original file (fermi, t-paliad-207) only had the UPDATE,
no set_config wrapper.

Patch: prepend the standard 'SELECT set_config(paliad.audit_reason,
...)' at the top so the trigger sees the reason. Same shape as every
other migration that mutates deadline_rules.

Manual recovery already applied via head MCP — UPDATE'd the 2 rows
with audit_reason set, marked tracker version=99 dirty=false,
force-restarted the container which booted clean. This commit aligns
the in-repo file with the recovered prod state. Idempotent: the
WHERE clause matches only rows that still carry with_po, so re-apply
is a no-op.
2026-05-18 17:33:01 +02:00
mAi
dece61107b Merge: t-paliad-207 — fermi's polish session (jurisdiction prefix + trigger-event label + flag rows + youpc rule links + DE sub-group headers + R.19 Einspruch as always-available; mig 099 NULLs with_po flag on RoP.019.1 rows) 2026-05-18 17:29:43 +02:00
mAi
8bf1626997 fix(mig): renumber drop_with_po_flag 098 → 099 (number collision with submission_codes_prefix_and_rename) 2026-05-18 17:29:21 +02:00
mAi
7f49851abf fix(t-paliad-207): drop with_po flag — R.19 Einspruch is always available, not flag-gated (mig 098)
m's correction 2026-05-18: the R.19 Einspruch (preliminary objection)
should not be flag-gated. It's an always-available optional submission
the defendant can make once the SoC is served — same logic as the
appeal-spawn rules in t-paliad-203 F2.3 ("the appeal is always a
possibility"). Removing the gate makes the row a normal optional rule:
priority='optional' (unchanged, set by mig 095) gives the save-modal
the existing pre-uncheck behaviour without a separate checkbox.

**Migration 098** (idempotent): NULLs condition_expr on the two RoP.019.1
rows pinned by proceeding code (`upc.inf.cfi` + `upc.rev.cfi`). Re-apply
is a no-op via the WHERE clause matching the live shape. Live DB row
state will sync when Dokploy applies the migration on next deploy — no
raw prod-write this turn (lesson from the previous shift's friction note).

**Frontend cleanup** — removes the two flag rows added to
verfahrensablauf.tsx + fristenrechner.tsx in the parent t-paliad-207
commit (inf-po-flag-row, rev-po-flag-row), the readFlags()/calculate()
push branches, the syncFlagRows() show/hide entries, and the change
listeners. Drops the 4 i18n keys (deadlines.flag.inf_po + rev_po,
DE + EN). Bun build clean: 2417 keys (was 2419, -2 keys × 2 langs).

Branch: mai/fermi/interactive-session @ third commit on top of Path A.
2026-05-18 17:29:14 +02:00
mAi
518b2d9617 feat(t-paliad-207): DE proceeding picker — sub-group headers + parallel labels (Path A)
m's 2026-05-18 ask: the 5 DE proceeding tiles followed three different
labelling conventions ("Verletzungsklage (LG)" / "Berufung OLG" /
"Nichtigkeitsverfahren" — instance in brackets vs not vs not even
present). Path A reshapes both the picker and the labels so a user
scanning "Deutsche Gerichte" sees the type→instance hierarchy at a
glance and every tile reads <court> (<procedural role>) in parallel.

**Picker structure (verfahrensablauf.tsx + fristenrechner.tsx):**
Inside the existing `<.proceeding-group data-forum="de">` block, the
single flat row of 5 tiles is now two sub-groups with mixed-case h5
headings — Verletzungsverfahren over LG/OLG/BGH, Nichtigkeitsverfahren
over BPatG/BGH. DE_TYPES split into DE_INF_TYPES (3) + DE_NULL_TYPES (2)
in both page shells.

**Labels (i18n.ts, DE + EN parallel):**
| Code           | Old DE                       | New DE                |
|---             |---                           |---                    |
| de.inf.lg      | Verletzungsklage (LG)        | LG (1. Instanz)       |
| de.inf.olg     | Berufung OLG                 | OLG (Berufung)        |
| de.inf.bgh     | Revision/NZB BGH             | BGH (Revision / NZB)  |
| de.null.bpatg  | Nichtigkeitsverfahren        | BPatG (1. Instanz)    |
| de.null.bgh    | Berufung BGH (Nichtigk.)     | BGH (Berufung)        |

Two new i18n keys carry the sub-group headings:
- deadlines.de.group.inf  — "Verletzungsverfahren" / "Infringement proceedings"
- deadlines.de.group.null — "Nichtigkeitsverfahren" / "Nullity proceedings"

**CSS (global.css):**
New `.proceeding-subgroup` + `.proceeding-subgroup-heading` rules,
co-located with `.proceeding-group h4`. Sub-heading sits one tier below
the h4 (mixed-case, no upper-tracking) so the two-level hierarchy reads
at a glance.

**What this does NOT do** — the "one long sequence" combined-timeline
behaviour (m's same ask, larger scope: spawn rules + de-duplication +
multi-instance UI) is filed as m/paliad#41 and stays a separate
delivery. Per-instance tiles keep their meaning either way.

Build hygiene: go build/vet clean; bun run build clean (2419 keys, +2).
2026-05-18 17:29:14 +02:00
mAi
4131d2e2a6 feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 17:29:14 +02:00
mAi
d507db22a7 fix(mig 098): exempt orphan rules from §6.2 NULL-check (proceeding_type_id IS NULL)
Recovery during the prod outage uncovered a second mig 098 bug: §6.2
assertion '0 NULL submission_code on active+published rows' counted
the 77 orphan rules (proceeding_type_id IS NULL, cross-cutting
Wiedereinsetzung / Schriftsatznachreichung pattern) and rejected the
migration. Patch: gate the NULL count on `proceeding_type_id IS NOT
NULL` so orphans pass through. Migration already applied to prod via
manual recovery with the same patched assertion; this commit aligns
the in-repo file with the deployed state.
2026-05-18 17:28:19 +02:00
mAi
a0a3ec32a3 fix(mig 098): relax submission_code shape regex to allow digits in suffix
Mig 098 (t-paliad-209, ohm) crash-looped paliad.de prod for ~2h: §6.1
assertion regex `^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$` rejects
EPA rule codes that carry the statutory rule number in the suffix —
e.g. `epa.opp.boa.r106`, `epa.grant.exa.r71_3`, `epa.opp.opd.r116`,
`epa.opp.opd.r79_further`, `epa.opp.boa.entsch2`, `epa.opp.boa.r116`.
Migration's UPDATE step succeeds against these rows; the transactional
assertion blows them up; rollback leaves the migration tracker dirty
at version 98 and the container refuses to start.

Patch: allow `[a-z_0-9]` per segment instead of `[a-z_]` in both the
SQL assertion (mig 098 §6.1) and the matching Go shape regex
(submission_codes_shape_test.go). Same change in both spots so the
runtime sanity test stays aligned with the SQL invariant.

Manual recovery already applied: forced
`paliad.paliad_schema_migrations.version` back to 97 with `dirty=false`
so the next deploy retries mig 098 from scratch against the patched
file. No data state changed (mig 098 ran inside a transaction and
fully rolled back — snapshot table, prefix UPDATE, and column rename
all reverted).

go build ./... clean. TestProceedingCodeShapeRegexStandalone green.
2026-05-18 16:52:38 +02:00
mAi
f9d32a90e7 Merge: t-paliad-207 — fermi's polish (jurisdiction prefix, trigger-event label, flag rows, youpc rule links, R.19 Einspruch label) 2026-05-18 16:37:54 +02:00
mAi
a18b825bee feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 15:58:26 +02:00
mAi
7d275cac6b Merge: t-paliad-210 — mig 097 legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through) 2026-05-18 15:54:47 +02:00
mAi
21727bf1ca feat(db): mig 097 — legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through)
t-paliad-210 / paliadin-head msg 2002 + 2006. Applies huygens's HIGH/MED
proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
(commit 391be09) plus m's FLAG walk-through:

  § 1  Easy wins                — 6 rows (rule_code only).
  § 2  HIGH/MED proceeding-typed — 15 rows.
  § 3  HIGH/MED orphans         — 47 rows.
  § 4  FLAG-A dedup (clean only) — 1 canonical fill + 3 archives
                                  (Wiedereinsetzung §123-PatG twin,
                                  Berufungsschrift, Berufungsbegründung).
                                  Mängelbeseitigung 6× and Beginn-
                                  Hauptsache 2× DEFERRED pending m's call
                                  on distinct-context rule_codes[].
  § 5  FLAG-B court-scheduled    — 26 rows. RoP.111 / RoP.118 / § 285 ZPO
                                  / § 300 ZPO / § 47 PatG etc.
  § 6  FLAG-C/D rubber-stamp     — 5 rows. RoP.52 / RoP.235.1 / § 273 ZPO.
  § 7  FLAG-E service triggers   — 6 rows. § 317 ZPO / § 99 / 47 / 79 PatG
                                  / R. 111 EPÜ.
  § 8  FLAG-F combined-pleading  — 5 rows via rule_codes[] multi-cite.
  § 9  FLAG-G/H/I + RoP.271.b    — 13 rows. Patentänderung INF/REV split,
                                  H sub-paragraphs, RoP.069 by analogy,
                                  + RoP.271.b secondary cite on 5 UPC
                                  initial submissions.
  § 10 R.19 label rename         — defensive backstop for fermi's prod
                                  write (t-paliad-207 consolidated).
  § 11 RoP.49.1 → RoP.049.1      — padding normalization on rev.defence.

FLAG-J 3 rows (d124c95b / 002c2ba7 / 902cc5d5) left NULL for m's
/admin/rules pickup. 11 rows total stay NULL post-mig (3 FLAG-J + 8
deferred dedup).

Snapshot table paliad.deadline_rules_pre_097 preserves pre-mig state
including the distinct rule_codes[] on the deferred Mängelbeseitigung +
Beginn-Hauptsache sets.

Dry-run on supabase produced expected counts:
  null_count=11, old_outlier=0, new_padded=2

Idempotent: re-applying matches no rows. Audit-trail through mig 079
trigger via set_config(paliad.audit_reason, ..., true).
2026-05-18 15:39:03 +02:00
mAi
d126913185 Merge: t-paliad-209 — workstream B (submission_code rename + prefix + Rechtsgrundlage column) 2026-05-18 15:11:52 +02:00
mAi
ea29165d2f feat(t-paliad-209): rename Code → Submission Code + add Rechtsgrundlage column
Workstream B frontend sweep — matches mig 098 + the Go sweep. The
/admin/rules surfaces now distinguish submission_code (the rule's
filing identifier within a proceeding, e.g. upc.inf.cfi.soc) from
rule_code (the legal citation, e.g. RoP.013.1).

Admin rules list (/admin/rules):
- Column header renamed "Code" → "Submission Code / Einreichung-Kennung"
- New "Rechtsgrundlage" column shows rule_code alongside the submission
  code; the old single-column fallback (rule_code || code) is gone.
- Filter-search placeholder updated to "Name, Submission Code,
  Rechtsgrundlage…"
- Rule interface: code → submission_code field.

Admin rules edit (/admin/rules/{id}/edit):
- f-code → f-submission-code; input is now read-only with a
  upc.inf.cfi.soc-style placeholder (consistent with the backend
  RulePatch which doesn't allow editing the submission code).
- Labels reframe rule_code as "Rechtsgrundlage (Kurzform)" and
  legal_source as "Rechtsgrundlage (Langform)" so the legal-citation
  pair is named consistently with the list column.
- Rule interface: code → submission_code field.

i18n: new keys admin.rules.col.submission_code,
admin.rules.col.legal_citation, admin.rules.edit.field.submission_code
in both DE + EN; old admin.rules.col.code + admin.rules.edit.field.code
removed.

bun run build clean.
2026-05-18 15:06:18 +02:00
mAi
bc5b3557d0 feat(t-paliad-209): rename DeadlineRule.Code → SubmissionCode across Go layer
Workstream B Go sweep — matches mig 098. Every place the deadline-rules
service reads/writes the per-rule identifier now uses the new column
name and the new struct field. Distinct from rule_code (legal citation)
and from proceeding_types.code (the proceeding's 3-segment code).

Touch points:
- models.DeadlineRule.Code → SubmissionCode (db + json tags renamed
  in lockstep — JSON contract `submission_code` is the new shape).
- deadline_rule_service: ruleColumns SELECT list updated.
- rule_editor_service: CreateRuleInput.Code → SubmissionCode (json tag
  too), INSERT + CloneAsDraft SELECT updated.
- projection_service: lookupRuleByCode → lookupRuleBySubmissionCode
  (SQL WHERE clause + error message); every r.Code / parent.Code /
  rule.Code / first.Code / src.rule.Code read renamed.
- fristenrechner: r.Code / prev.Code / rule.Code reads renamed in
  Calculate (parent-anchor + override-key + computed-by-code map) and
  in CalculateRule's LocalCode emission; the proceeding-code+submission-
  code resolver query uses `submission_code = $2`.
- event_trigger_service / deadline_calculator: r.Code reads renamed.

UIDeadline.Code (the calculator's wire response) is unchanged — that
field is a separate API contract pointing at the same value; renaming
it would force every frontend deadline-renderer through a contract
break that isn't part of this workstream.

Test fixtures updated to the new SubmissionCode field name; live-DB
tests updated to the post-mig-098 prefixed values (`inf.sod` →
`upc.inf.cfi.sod` etc.). New submission_codes_shape_test asserts
every active+published row matches the 4+-segment proceeding-prefixed
shape (sibling of TestProceedingCodeShape; mirrors mig 098 §6.1).

go build ./... clean. go test ./internal/... green.
2026-05-18 15:06:04 +02:00
mAi
bd2c7a217e feat(t-paliad-209): mig 098 prefix submission codes + rename code → submission_code
m's 2026-05-18 call (workstream B): the paliad.deadline_rules.code field
is a SUBMISSION identifier (the filing/event within a proceeding), not
the legal-citation rule code (which lives in rule_code / legal_source).
Two cleanups land in this migration:

1. DATA — prefix every existing submission code with its proceeding
   code so submission codes carry the full hierarchical shape:
       inf.soc       (on upc.inf.cfi)  → upc.inf.cfi.soc
       de_inf.klage  (on de.inf.lg)    → de.inf.lg.klage
       de_inf_bgh.revision (on de.inf.bgh) → de.inf.bgh.revision
   Idempotent: WHERE NOT LIKE pt.code || '.%' skips already-prefixed
   rows so re-running is a no-op.

2. SCHEMA — rename paliad.deadline_rules.code → submission_code so
   future devs don't conflate it with rule_code (legal citation) or
   proceeding_types.code. The rename is guarded by a column-existence
   check, idempotent on a second run.

Drops + recreates the deadline_search materialized view because its
SELECT bakes `dr.code AS rule_local_code` (mig 051 §4); the rebuild
sources from `dr.submission_code` and reproduces every index from mig
051 verbatim.

Backup snapshot table paliad.deadline_rules_pre_098 captures the rows
before the prefix step; serves as the audit anchor and the down's
source.

Hard assertions (§6) gate the migration on:
- every active+published row matches the 4+-segment proceeding-prefixed
  shape regex
- no NULL submission_code on active+published rows
- the column was actually renamed
2026-05-18 15:05:46 +02:00
mAi
edcf41d203 Merge: t-paliad-208 — legal-citation backfill proposal (huygens, doc only) 2026-05-18 14:57:25 +02:00
mAi
391be09b1e docs(t-paliad-208): legal-citation backfill proposal for 130 deadline_rules
Researcher draft for Workstream A — per-rule proposals for rule_code +
legal_source on the 130 active+published deadline_rules with rule_code IS
NULL. Grouped by proceeding (53 PT rows) and orphan-bucket (77 rows with
proceeding_type_id IS NULL).

~75 HIGH/MED proposals, ~47 FLAG entries pending m's call (court-set
event-markers, combined-pleading rows, ambiguous orphans, RoP
sub-paragraph spot-checks). Profiles the field convention from the 83
already-populated rows. READ-ONLY phase: no DB writes, no migration yet
— mig 097 follows once m signs off.

Side-fix candidate: normalize the one outlier RoP.49.1 -> RoP.049.1 on
rev.defence as part of mig 097.
2026-05-18 14:56:42 +02:00
mAi
d76b8a6c64 Merge: small UX — deadline-done confirm modal + cascade ändern i18n 2026-05-18 14:26:19 +02:00
mAi
061780dea5 fix(frontend): two small UX issues — deadline-done confirm + i18n the cascade "ändern"
1. /deadlines list ticking the complete-checkbox now goes through
   window.confirm() before firing PATCH /api/deadlines/{id}/complete.
   The deadline title is interpolated into the prompt so the user sees
   what they're closing. Matches the existing window.confirm() pattern
   used in projects-detail / admin-team / approvals-withdraw etc. —
   no custom modal layer.

2. The cascade row "ändern" button in the deadline calculator stayed
   in German on the EN side. data-i18n="deadlines.row.edit" was set
   correctly but applyTranslations() only runs at page init and on
   lang-toggle; the cascade re-renders on every state change without
   re-hydrating, so the static "ändern" fallback in the HTML stuck.
   Render the label via t() directly in the template — same pattern
   the rest of the cascade uses, no hydration dependency.

Both i18n keys land on both DE and EN sides (deadlines.complete.confirm
+ existing deadlines.row.edit). bun run build clean, 2414 keys.
2026-05-18 14:26:13 +02:00
mAi
b07702a095 Merge: t-paliad-206 — proceeding-code rename to lowercase dot-form (mig 096 + Go sweep + frontend sweep + taxonomy spec) 2026-05-18 12:14:38 +02:00
mAi
aa9e47fda9 feat(t-paliad-206): switch frontend to lowercase dot-form proceeding codes
Sweep of frontend/src/* for the proceeding-code rename landed by
mig 096. Same scope as the Go sweep — comments + literal string
codes substituted, plus the visible additions:

- fristenrechner.tsx / verfahrensablauf.tsx UPC_TYPES gain
  upc.ccr.cfi as a fourth UPC option ("Widerklage auf Nichtigkeit");
  it surfaces in the picker and renders the determinator routing
  notice from proceeding_mapping.ResolveCounterclaimRouting.
- i18n.ts deadlines.* keys renamed to mirror the new codes exactly
  (`deadlines.upc.inf.cfi`, …). DE + EN sides in sync.
- frontend/src/client/fristenrechner.ts fristenrechnerCodeToCascadeSegment
  rekeyed to new codes; upc.ccr.cfi shares the upc-inf kebab segment
  because the event_categories slug taxonomy is not renamed and ccr
  resolves to inf-rules anyway.
- client/views/verfahrensablauf-core.ts court-picker conditions
  rewritten against the new codes.

Bun build clean (i18n-keys.ts regenerated from the canonical map).
2026-05-18 12:13:39 +02:00
mAi
216abbfc98 feat(t-paliad-206): switch Go layer to lowercase dot-form proceeding codes
Sweeps internal/services + internal/handlers + internal/models to use
the new proceeding codes landed by mig 096. Stable Code* constants
live in internal/services/proceeding_mapping.go so a future rename
needs to touch one file.

Substantive changes:
- proceeding_mapping.go gains ResolveCounterclaimRouting() — the
  cascade resolver that routes upc.ccr.cfi (illustrative peer) back
  to upc.inf.cfi with with_ccr=true as default flag (design doc S1).
- deadline_search_service.go forum-bucket map updated; upc.ccr.cfi
  added to upc_cfi since it is a CFI peer.
- project_service.go CreateCounterclaim default lookup parameterised
  so the SQL string carries the constant, not a literal.
- proceeding_codes_shape_test.go: new file. Validates the shape
  regex standalone (always runs) and walks live DB rows asserting
  every active fristenrechner row matches the new shape + every
  stable Code* constant resolves to exactly one active row.

Comments and test fixtures throughout the Go tree updated to the
new shape. Tests pass under `go test ./internal/... -short`.
2026-05-18 12:13:24 +02:00
mAi
cce0ada3ce feat(t-paliad-206): mig 096 — rename proceeding_types.code to lowercase dot-form
19 active fristenrechner codes renamed from UPPER_SNAKE to the
lowercase three-position dot-separated taxonomy ratified by m on
2026-05-18 (see docs/design-proceeding-code-taxonomy-2026-05-18.md).
IDs are stable; only the `code` STRING changes.

Adds upc.ccr.cfi as an illustrative peer of upc.inf.cfi
(is_active=true, no rules — Go code routes cascade hits back to
inf.cfi with with_ccr=true).

Also updates the soft `proceeding_type_code` references on
paliad.event_category_concepts so the soft-join through
proceeding_types.code keeps resolving, refreshes the
deadline_search materialized view, and installs the
paliad_proceeding_code_shape CHECK constraint enforcing
`^[a-z]+\\.[a-z]+\\.[a-z]+$` on every active row.

Idempotent: every UPDATE is guarded on the OLD code; INSERT uses
WHERE NOT EXISTS; CHECK is dropped-then-recreated by name. Backup
snapshot lives in paliad.proceeding_types_pre_096. Dry-run on the
live youpc DB (BEGIN; … ROLLBACK) confirmed 20 active rows on the
new shape, 0 old codes left, 1 active upc.ccr.cfi.
2026-05-18 12:13:13 +02:00
mAi
e857829ac2 docs(t-paliad-206): proceeding-code taxonomy spec — lowercase dot-separated
Captures m's 2026-05-18 ratification of the new fristenrechner
proceeding-code convention `<jurisdiction>.<X>.<Y>` and the 5
sub-decisions: ccr.cfi is an illustrative peer that routes back to
inf.cfi with with_ccr; damages-appeal stays bundled into
upc.apl.merits; NZB at BGH is a flag, not a separate proceeding;
DPMA appeals stay generic with source differentiation at rule level.

This document is the source of truth for mig 096 (lands next) and the
post-mig proceeding_mapping.go.
2026-05-18 12:13:02 +02:00
mAi
1d535a2175 Merge: t-paliad-205 — mig 095 fristen gap-fill (4 new rules + 4 patches per t-203 decisions) 2026-05-18 11:47:23 +02:00
mAi
af30c06d9b feat(t-paliad-205): mig 095 — ingest t-paliad-203 fristen gap-fill deltas
Codifies curie's 4 new rules + 4 patches from
docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3 (m's decisions).

NEW (4):
  inf.prelim         UPC_INF  parent=inf.soc      1mo  RoP.019.1  flag=with_po
  rev.prelim         UPC_REV  parent=rev.app      1mo  RoP.019.1  flag=with_po
  inf.appeal_spawn   UPC_INF  parent=inf.decision 2mo  RoP.220.1.a  always-fire  → UPC_APP
  rev.appeal_spawn   UPC_REV  parent=rev.decision 2mo  RoP.220.1.a  always-fire  → UPC_APP

PATCH (4):
  de_inf.klage       legal_source NULL → 'DE.ZPO.253'
  de_inf.anzeige     no change (already correct — explicit in audit log)
  de_inf.erwidg      is_court_set false → true + §276 Abs.1 S.2 description
  de_inf.berufung    defensive verify legal_source = 'DE.ZPO.517'

Idempotent via WHERE NOT EXISTS (no unique index on (proceeding_type_id,
code) — mig 093 left archived rows sharing codes with their published
successors, so ON CONFLICT isn't available). UPDATEs guarded by clauses
that only fire when the row still has the old value.

Backup snapshot in paliad.deadline_rules_pre_095 (CREATE TABLE IF NOT
EXISTS); down migration restores from it. Hard assertions verify all 4
new rules landed active+published, de_inf.erwidg flipped to court-set,
both spawn rules chain to a valid proceeding_type id=11.

Dry-run verified end-to-end against the live Supabase corpus inside
BEGIN/ROLLBACK; idempotency confirmed by running INSERT+UPDATE twice
in the same transaction.
2026-05-18 11:46:12 +02:00
mAi
8833c6975a Merge: m's decisions on t-paliad-203 FLAGs (final shape: 4 new rules + de_inf.erwidg court-set flip) 2026-05-18 11:26:34 +02:00
mAi
0123d11c6e docs(t-paliad-203): capture m's decisions on the 12 FLAGs (2026-05-18)
m + paliadin walked the open questions; new §0.3 records the calls so
the proposal doc reflects the final shape before m ingests via
/admin/rules. Net stays at 4 new rules (2 PO + 2 always-fire merits-
appeal spawns). de_inf.erwidg flips to court-set per ZPO §276(1) S.2.
No ccr-defendant PO, no ccr.appeal duplication, no R.263 deadline.
2026-05-18 11:26:32 +02:00
mAi
4d2382679b Merge: t-paliad-203 — fristenrechner gap-fill proposals (curie's research doc, no code changes) 2026-05-18 11:19:06 +02:00
mAi
35aa5e63c0 docs(t-paliad-203): Fristenrechner gap-fill proposals — 4 new rules + 3 polish PATCHes
Drafts the 4 coverage questions the mig 093 commit body left open for
legal review (t-paliad-200 closeout):

  1. Preliminary Objection (RoP 19) on UPC_INF + UPC_REV — 2 new rules,
     party=defendant, 1 month from SoC/SfR served, flag-gated with_po.
  2. Cross-proceeding APP spawn (RoP 220.1(a)) from UPC_INF + UPC_REV
     into the UPC merits-appeal proceeding — 2 spawn rules, party=both,
     2 months from R.118 decision, flag-gated with_appeal. Third
     Pipeline-A relic (ccr.appeal) recommended not seeded — CCR appeal
     is structurally absorbed into inf.appeal_spawn because one R.118
     decision = one appeal window in the unified UPC_INF (CCR-as-flag)
     model.
  3. ccr.amend / rev.amend — claim "safe to drop" verified for patent
     amendment (fully covered by inf.app_to_amend / rev.app_to_amend
     chains under with_ccr+with_amend / with_amend flags). R.263 case-
     amendment is court-discretionary; recommended NOT modelled.
  4. zpo.* family — klage / vertanz / berufung redundancy verified
     (de_inf.klage, de_inf.anzeige, de_inf.berufung / de_inf_olg.berufung
     cover them). klageerw exposes a discrepancy on de_inf.erwidg
     (6-week heuristic vs ZPO §276.1 S.2 court-set 2-week floor) — flagged
     as a PATCH on the existing row, not a new rule. Task brief's mention
     of "Vertagungsantrag" is a misread of zpo.vertanz (= Verteidigungs-
     anzeige, not Vertagungsantrag); §227 itself recommended NOT modelled.

Net: 4 new rules drafted in Track B, 3 optional PATCHes in Track A, 12
FLAGs surfaced for m's decision before /admin/rules ingest. Appeal target
referenced by ROLE (not code) pending t-paliad-204 proceeding-code
rename — m picks final spawn_proceeding_type_id at ingest.

Per-rule template matches docs/proposals/orphan-concepts-2026-05-15.md.
Read-only research; no DB writes, no migration files. The spawn_proceeding_type_id
column is unused in live data today — these spawn rules will be the
first real consumer.
2026-05-18 11:18:23 +02:00
mAi
3c9ecabf17 Merge: t-paliad-202 — inbox grey-out illegal actions (replace alert-after-click with server-tagged viewer_can_approve / viewer_is_requester flags) 2026-05-17 12:45:32 +02:00
mAi
aa82434af9 fix(t-paliad-202): grey out inbox actions instead of erroring on illegal click
m's UX bug (2026-05-17, paliad.de prod): clicking Genehmigen/Ablehnen/
Zurückziehen on a row the viewer can't act on alerted ("Eigengenehmigung
nicht zulässig.", "Sie haben nicht die erforderliche Rolle.") after the
POST round-trip. m's ask: "approval that i cannot grant should have the
'Genehmigen' button greyed out... that would be better than showing an
error when I try."

Backend (internal/services/approval_service.go):
- ApprovalRequestView gains viewer_can_approve + viewer_is_requester
  booleans. Resolved server-side per caller — false on self-authored rows
  (caller == requester), true when the eligibility predicate matches.
- Extract the eligibility EXISTS-block into approvalEligibilitySQL const
  and reuse it in ListPendingForApprover (WHERE), PendingCountForUser
  (WHERE), and the new viewer_can_approve SELECT expression. Single
  source of truth for the gate, identical to canApprove.
- ListPendingForApprover, ListSubmittedByUser, and GetRequest all bind
  $1 = callerID so the SELECT computes the flags inline (one query, no
  N+1). GetRequest's signature grows a callerID arg; the handler passes
  the authenticated user.

Frontend (frontend/src/client/views/shape-list.ts):
- ApprovalDetail picks up the two booleans (optional — falsy is safe:
  it disables, never falsely enables).
- approvalActionBtn renders the button as before but flips
  btn.disabled + sets a tooltip via disabledReasonFor: approve/reject
  share the viewer_can_approve gate (self → self_approval tooltip;
  unauthorized → not_authorized); revoke needs viewer_is_requester.
- All three buttons still render on every pending row so users see
  what's possible — the disabled+tooltip combo explains what's not.

i18n + CSS:
- 3 new keys × DE/EN: approvals.disabled.{self_approval,
  not_authorized,revoke_not_requester}.
- .inbox-row-action:disabled neutralises the .btn-primary/danger/
  secondary variant via opacity + not-allowed + muted tokens.

Tests:
- internal/services/approval_service_test.go::TestApprovalService_ViewerFlags
  is a 4-case table-driven live-DB test (skips without TEST_DATABASE_URL):
  self-authored (false/true), eligible peer (true/false), non-eligible
  viewer (false/false), global_admin (true/false). Also asserts the flags
  on ListPendingForApprover + ListSubmittedByUser rows.

Defence-in-depth preserved: server still rejects illegal POSTs with the
same error contract, and the alert path stays in inbox.ts for the race
where state changes between render and click.
2026-05-17 12:44:29 +02:00
mAi
4f66feffce Merge: fix(projects) — unbreak Create + 6-digit CM constraint 2026-05-17 12:30:58 +02:00
mAi
bdd4999213 fix(projects): unbreak Create — drop $1::text reuse + tighten CM CHECK to 6 digits
Two issues m hit and reported in one breath while adding a project:

1. **Internal error on POST /projects** (prod-only, surfaced at 10:23). Both
   ProjectService.Create and CreateCounterclaim re-referenced the uuid
   parameter `$1` as `$1::text` to fill the path placeholder. Postgres'
   planner deduced conflicting types for `$1` (uuid in the id column,
   text in the cast) and rejected the prepared statement with 42P08
   "inconsistent types deduced for parameter". The path placeholder
   value is irrelevant — paliad.projects_sync_path() (BEFORE INSERT
   trigger from mig 018/021) always overwrites it from id and parent
   path. Fix: replace `$1::text` with a literal '' in both INSERTs,
   keeping the parameter list decoupled from the id column's type.
   Same comment now anchors the rationale on both call sites.

2. **CM number length — 6 digits, not 7.** m's correction; mig 018's
   `^[0-9]{7}$` CHECK on paliad.projects.client_number and
   matter_number was wrong. Mig 094 snapshots affected rows to
   paliad.projects_pre_094, NULL-s the 3 surviving 7-digit test
   values (2 client_numbers, 1 matter_number), then swaps the legacy
   `projekte_*_check` constraints from {7} to {6}. Frontend pattern,
   maxLength, placeholder, labels, and i18n hint flipped from 7 → 6
   on both DE and EN sides; format hint reads CCCCCC.MMMMMM now.

Dry-run against live DB (BEGIN..ROLLBACK):
- Fixed Create SQL: trigger populates path = id::text (36 chars). ✓
- Mig 094: 2 rows snapshotted, 0 clients/matters remain after clear,
  0 rows violate the new 6-digit CHECK. ✓

go build, go test ./internal/..., bun run build all clean.
2026-05-17 12:30:53 +02:00
mAi
cbcc67bae7 Merge: t-paliad-200 — Slice 9 follow-up B (archive 40 Pipeline-A litigation rules, drop 7 litigation proceeding_types — Phase 3 closeout complete) 2026-05-16 01:30:03 +02:00
mAi
40e49e87d4 refactor(t-paliad-200): Slice 9 follow-up B — retire litigation category from rule corpus
Lorenz's Slice 9 (t-paliad-195) deferred mig 093 because 40 active
paliad.deadline_rules still pointed at the 7 litigation-category
proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). Phase 3
Slice 5 (mig 087/088) already retired the category from project-binding;
this migration retires it from the rule corpus.

PLAN CHOICE (audit-gated, paliadin-approved): archive-all-40 rather than
the original re-parent plan. The audit found that 23 of 40 Pipeline-A
rules share their `code` with an existing fristenrechner rule on the
proposed re-parent target (e.g. inf.oral exists on both INF and
UPC_INF). Re-parenting would leave two rules with identical
(proceeding_type_id, code), breaking the implicit per-proceeding
rule_code identity contract keyed off by projection / search /
rule_editor. The fristenrechner rules are clearly the production
version (proper German names, legal_source pinned to UPC.RoP citations,
full bilateral chains, intra-proceeding counterclaim handling); the
Pipeline-A rules are stubs (English-only, mostly NULL legal_source,
duration_value=0 for 28 of 40, no spawn_proceeding_type_id wiring).

Migration 093 sequence (atomic):
  1. Snapshot proceeding_types_pre_093 + deadline_rules_pre_093 as
     permanent audit anchors.
  2. INSERT _archived_litigation pt (category='archived',
     is_active=false, jurisdiction='UPC') to home the rules.
  3. UPDATE all 40 rules → archive pt + lifecycle_state='archived' +
     is_active=false. Captured in paliad.deadline_rule_audit via the
     mig 079 trigger.
  4. DELETE the 7 litigation rows from paliad.proceeding_types (now
     safe — nothing references them).
  5. Hard assertions: 0 litigation rows survive, exactly 40 rules on
     the archive pt, every snapshot row matches a surviving rule by id.

Critical FK note: deadline_rules.proceeding_type_id is ON DELETE CASCADE
→ proceeding_types(id). A naive DELETE of the 7 litigation rows would
cascade-delete all 40 rules and break the FK from the 1 live deadline
("Lecker Frist", completed) that still references inf.rejoin/INF.
Re-homing the rules before deleting the pt rows is mandatory.

Verified via BEGIN..ROLLBACK against live DB: assertions pass, all 30
intra-litigation parent_id chains preserved, the live deadline FK
stays valid.

Test impact:
  internal/services/project_service_test.go:72 used to look up
  category='litigation' AND code='INF' to exercise the Slice 5 negative
  case. Post-mig-093 that lookup returns NULL. Rewritten to fetch any
  category <> 'fristenrechner' row (the _archived_litigation pt is the
  canonical post-093 row); defence-in-depth coverage of both the Go
  service guard and the mig 088 SQL trigger is preserved.

SURFACED FOR LEGAL REVIEW (4 coverage questions the audit found, to be
triaged as follow-up tasks):

  1. inf.prelim (Preliminary Objection, RoP 19, 1 month) — not present
     on UPC_INF. Possible coverage gap; legal review to decide whether
     to add it to the fristenrechner ruleset.
  2. inf.appeal / rev.appeal / ccr.appeal as cross-proceeding spawns
     into UPC_APP (2 months, UPC.RoP.220.1) — fristenrechner UPC_APP
     currently starts standalone with no spawn from UPC_INF/UPC_REV.
     Possible UX gap; Pipeline-A versions had
     spawn_proceeding_type_id=NULL so they weren't functional spawns
     either.
  3. ccr.amend / rev.amend (spawn rules) — superseded by
     inf.app_to_amend / rev.app_to_amend on UPC_INF / UPC_REV. Safe to
     drop; no action needed.
  4. zpo.klage / zpo.vertanz / zpo.klageerw / zpo.berufung — no UPC
     analogue; redundant with the DE_INF / DE_INF_OLG / DE_INF_BGH and
     DE_NULL / DE_NULL_BGH chains. Safe to drop; no action needed.

Files:
  internal/db/migrations/093_retire_litigation_category.up.sql   (new)
  internal/db/migrations/093_retire_litigation_category.down.sql (new)
  internal/services/project_service_test.go                      (test rewrite)
2026-05-16 01:29:31 +02:00
mAi
2686d43a38 Merge: t-paliad-199 — Slice 9 follow-up A (drop legacy event_deadlines tables, EventDeadlineService refactored onto deadline_rules) 2026-05-16 01:18:21 +02:00
72 changed files with 6587 additions and 599 deletions

View File

@@ -0,0 +1,172 @@
# Proceeding-code taxonomy (t-paliad-204 ratified 2026-05-18)
> Source of truth for `paliad.proceeding_types.code`. Every active row's
> `code` MUST conform to the convention below. This document anchors
> migration 096 (`internal/db/migrations/096_proceeding_code_rename.up.sql`)
> and the post-migration determinator + fristenrechner mapping in
> `internal/services/proceeding_mapping.go`.
## 0. Why we renamed
The historical `code` strings (`UPC_INF`, `DE_INF`, `EPA_OPP`, …) were
UPPER_SNAKE jurisdiction-glued-to-acronym slugs. They were structurally
opaque and the taxonomy grew unevenly as more proceedings entered the
fristenrechner — `UPC_APP` covers all UPC appeals, `DE_INF_OLG` /
`DE_INF_BGH` carry the instance hint inline, `EP_GRANT` is the only EPA
row with no `EPA_` prefix at all. The mapping in
`internal/services/proceeding_mapping.go` had to special-case appeal
ambiguities (no instance hint on UPC_APP, none on the DE side either).
After mig 095 landed the t-paliad-205 fristen gap-fill, m and paliadin
ratified a uniform convention for the corpus, captured here.
## 0.1 Convention
Active proceeding codes are lowercase, dot-separated, three positions:
<jurisdiction>.<X>.<Y>
* **`<jurisdiction>`** — one of `upc`, `de`, `epa`, `dpma`.
* **`<X>` / `<Y>`** — contextual; for first-instance proceedings they are
`<substantive-type>.<forum>` (e.g. `de.inf.lg` for Verletzungsklage am
Landgericht). For appeals they are `<appeal-type>.<scope>` (e.g.
`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`).
* The CHECK constraint installed by mig 096 enforces
`code ~ '^[a-z]+\.[a-z]+\.[a-z]+$'` on every active row, with a
carve-out for the legacy `_archived_litigation` bucket
(`code ~ '^_archived_'`).
The convention is forward-looking: any new fristenrechner row added
after mig 096 MUST conform — no further UPPER_SNAKE codes.
## 0.2 Ratified taxonomy
### UPC
| New code | Old code | id | Notes |
|--------------------|------------------|----|------------------------------------------------------------------------|
| `upc.inf.cfi` | `UPC_INF` | 8 | Verletzungsverfahren, CFI |
| `upc.rev.cfi` | `UPC_REV` | 9 | Nichtigkeitsverfahren, CFI |
| `upc.ccr.cfi` | _new_ | _new_ | Widerklage auf Nichtigkeit — illustrative peer of `upc.inf.cfi`. Rules live on `upc.inf.cfi` with `with_ccr=true`. See §1 sub-decision S1. |
| `upc.pi.cfi` | `UPC_PI` | 10 | Einstweilige Maßnahmen |
| `upc.dmgs.cfi` | `UPC_DAMAGES` | 17 | Schadensbemessung |
| `upc.disc.cfi` | `UPC_DISCOVERY` | 18 | Bucheinsicht |
| `upc.apl.merits` | `UPC_APP` | 11 | Hauptberufung — covers inf + rev + ccr + damages-merits appeals |
| `upc.apl.order` | `UPC_APP_ORDERS` | 20 | 15-Tage-Beschwerde gegen Anordnungen (R.220 (1)(c)) |
| `upc.apl.cost` | `UPC_COST_APPEAL`| 19 | Kostenbeschwerde |
### DE
| New code | Old code | id | Notes |
|---------------------|------------------------|----|-------------------------------------------------------------|
| `de.inf.lg` | `DE_INF` | 12 | Verletzungsklage am Landgericht |
| `de.inf.olg` | `DE_INF_OLG` | 25 | Berufung am OLG |
| `de.inf.bgh` | `DE_INF_BGH` | 26 | Revision + NZB merged — `with_nzb` flag on NZB-detour rules |
| `de.null.bpatg` | `DE_NULL` | 13 | Nichtigkeitsverfahren am BPatG |
| `de.null.bgh` | `DE_NULL_BGH` | 27 | Nichtigkeitsberufung am BGH |
### EPA
| New code | Old code | id | Notes |
|---------------------|--------------|----|------------------------------------------------|
| `epa.grant.exa` | `EP_GRANT` | 16 | EP-Erteilungsverfahren |
| `epa.opp.opd` | `EPA_OPP` | 14 | Einspruchsverfahren |
| `epa.opp.boa` | `EPA_APP` | 15 | Einspruchsbeschwerde (Board of Appeal) |
### DPMA
| New code | Old code | id | Notes |
|-----------------------|-------------------------|----|----------------------------------------------------------------|
| `dpma.opp.dpma` | `DPMA_OPP` | 28 | Einspruch beim DPMA |
| `dpma.appeal.bpatg` | `DPMA_BPATG_BESCHWERDE` | 29 | Beschwerde am BPatG (generic — source differentiated at rule level) |
| `dpma.appeal.bgh` | `DPMA_BGH_RB` | 30 | Rechtsbeschwerde am BGH (generic — source differentiated at rule level) |
### Archived
| Code | id | Notes |
|-------------------------|----|----------------------------------------|
| `_archived_litigation` | 32 | Unchanged — Pipeline-A retired corpus |
IDs are stable. Only the `code` STRING changes. The FKs
`deadline_rules.proceeding_type_id`, `projects.proceeding_type_id`, and
`deadline_rules.spawn_proceeding_type_id` reference IDs, so the existing
rule corpus and spawn wiring (incl. mig 095's `spawn_proceeding_type_id=11`)
continue to work unchanged.
## 0.3 Sub-decisions (m's calls, 2026-05-18)
### S1 — `upc.ccr.cfi` visibility
`is_active=true`, visible in the determinator + dropdowns. **No rules
attached.** When the determinator surfaces it, the UI shows the hint:
> "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
> weiter."
Routing logic lands in `internal/services/proceeding_mapping.go` — when
the cascade resolves to `upc.ccr.cfi`, the mapping returns the
`upc.inf.cfi` id (=8) with `with_ccr=true` as a default flag. The peer
exists for taxonomic completeness so users searching for
"Widerklage" find an entry; it is not a separate rule namespace.
### S2 — Abbreviations
`dmgs` for damages, `disc` for discovery. m's call: short form keeps the
codes terse and the dot-separated shape readable.
### S3 — Damages appeal
**NO separate code.** `upc.apl.merits` covers damages appeals — the
spawn rules from `upc.dmgs.cfi` (none seeded today) would carry their
own `spawn_label`. Avoids a code like `upc.apl.dmgs` whose rules would
be empty for the foreseeable future.
### S4 — NZB at BGH
Single bucket `de.inf.bgh`. Rules diverging in the NZB-detour-path
(Nichtzulassungsbeschwerde when the OLG didn't grant Revision) use a
`with_nzb` flag instead of a separate proceeding type. Keeps the dropdown
list shorter and matches how m practitioners think about the BGH
instance — same destination, two ways to arrive.
### S5 — DPMA appeals
Generic `dpma.appeal.bpatg` / `dpma.appeal.bgh` — source-of-decision
differentiation (was it a DPMA decision being appealed? a BPatG
decision being further appealed to BGH?) lives at the rule level, not
the proceeding-type level. Keeps the code namespace flat.
## 0.4 Spawn-FK invariant
After mig 096, the spawn FK invariant from mig 095 still holds:
deadline_rules.spawn_proceeding_type_id = 11
↔ paliad.proceeding_types[id=11].code = 'upc.apl.merits'
Spawn rules from `upc.inf.cfi` / `upc.rev.cfi` chain to the appeal-merits
proceeding without code-string awareness. Same for any future spawn FK.
## 0.5 Not in scope
* `paliad.event_categories.slug` segments (`upc-inf`, `de-bgh-null`, …)
are NOT renamed. They are stable identifiers in a separate taxonomy and
their kebab form is presentation-layer (it appears in URL fragments).
Mig 096 only updates the `proceeding_type_code` text column on
`paliad.event_category_concepts` rows so the soft join through
`event_category_concepts → proceeding_types.code` keeps resolving.
* Fee-table keys (`EPA_OPPOSITION`, `UPC_APPEAL`, …) in
`internal/calc/fees.go` are NOT proceeding codes — they are fee-table
bucket keys with their own naming. Untouched.
* Forum bucket slugs (`upc_cfi`, `de_lg`, …) in
`ForumToProceedingCodes` are presentation buckets, not codes. The
values inside (`UPC_INF`, …) are the codes being renamed.
## 0.6 References
* `internal/db/migrations/096_proceeding_code_rename.up.sql` — the
migration that lands this rename.
* `internal/services/proceeding_mapping.go` — post-mig 096 mapping with
the ccr-routing helper (S1).
* `internal/services/proceeding_codes_shape_test.go` — Go test asserting
every active fristenrechner-category code matches the new shape regex.
* mig 095 (`internal/db/migrations/095_fristen_gap_fill.up.sql`) — the
immediate predecessor; spawn_proceeding_type_id=11 carries through.

View File

@@ -0,0 +1,435 @@
# Fristenrechner Gap-Fill Proposals — t-paliad-203
**Date:** 2026-05-18
**Author:** curie (researcher)
**Status:** DRAFT — for m's review, not yet ingested via `/admin/rules`
**Branch:** `mai/curie/fristenrechner-gap`
**Supersedes:** t-paliad-201 (cancelled)
**Source audit:** the four gaps surfaced by mig 093 commit message (t-paliad-200, `internal/db/migrations/093_retire_litigation_category.up.sql:40-54`) when 40 Pipeline-A litigation rules were archived under `_archived_litigation` and 7 litigation proceeding_types were dropped
---
## 0. Read-this-first — what was archived, what's left
mig 093 (commit `40e49e8`) retired the entire `category='litigation'` rule corpus by:
1. Snapshotting the 40 rules into `paliad.deadline_rules_pre_093` and the 7 proceeding_types into `paliad.proceeding_types_pre_093`.
2. Re-homing all 40 rules under a holding proceeding_type `_archived_litigation` (id 32, `category='archived'`, `is_active=false`, `lifecycle_state='archived'`).
3. Dropping `INF`, `REV`, `CCR`, `APM`, `APP`, `AMD`, `ZPO_CIVIL` from `paliad.proceeding_types`.
The commit's own body listed four open coverage questions for legal review (lines 40-54 of `093_retire_litigation_category.up.sql`):
| # | Pipeline-A rule(s) | Claim in commit body | This doc's verdict |
|---|---|---|---|
| 1 | `inf.prelim` (R.19, 1 month) | "not present on UPC_INF — possible coverage gap" | **Real gap.** Drafts 1.1 + 1.2 below. |
| 2 | `inf.appeal` / `rev.appeal` / `ccr.appeal` (RoP.220.1, 2 months) into UPC_APP | "fristenrechner UPC_APP starts standalone with no spawn" | **Real gap.** Drafts 2.1 + 2.2 below. Pipeline-A's three rules collapse to two in the unified UPC_INF (CCR-as-flag) world — see § 2 FLAG. |
| 3 | `ccr.amend` / `rev.amend` (spawn into AMD) | "superseded by `inf.app_to_amend` / `rev.app_to_amend` — safe to drop" | **Claim confirmed for patent amendment.** No new rules. § 3 documents the verification and surfaces R.263 (case-amendment) as a separate not-modelled item. |
| 4 | `zpo.klage` / `zpo.vertanz` / `zpo.klageerw` / `zpo.berufung` | "no UPC analogue; redundant with DE_INF / DE_INF_OLG / DE_INF_BGH / DE_NULL / DE_NULL_BGH" | **Claim confirmed for klage / vertanz / berufung.** `klageerw` exists on DE_INF but with a duration discrepancy worth m's attention. § 4 details. |
**Net: 4 substantive rule drafts** (1 PO on UPC_INF + 1 PO on UPC_REV + 2 merits-appeal spawns) — well under the "~4-10" estimate in the brief, and at the low end because two of the four gaps don't need new rules.
### 0.1 Naming convention notes
- **Appeal proceeding code referenced by ROLE, not by current code.** Per task brief and pairing with t-paliad-204 (proceeding-code abbreviation rework, m's review pending), the current `UPC_APP` (id=11) is referred to in proposals 2.1/2.2 as **"UPC infringement-appeal proceeding (RoP 220.1(a) main-judgment appeal)"** rather than by code. m picks the final `spawn_proceeding_type_id` when ingesting via `/admin/rules`.
- **Existing rule-code pattern.** Live `UPC_INF` rules use bare prefix `inf.*` (not `upc.inf.*`), e.g. `inf.sod`, `inf.def_to_ccr`. Live `UPC_REV` rules use `rev.*`. I follow that pattern: proposed PO rules are `inf.prelim` (matching Pipeline-A's archived name) and `rev.prelim`; proposed spawn rules are `inf.appeal_spawn` / `rev.appeal_spawn` (the `_spawn` suffix disambiguates them from the existing UPC_APP-root `app.notice`, which is the *target*, not the *source*).
- **Anchor semantics** (per `docs/audit-fristen-logic-2026-05-13.md` § 4 and `docs/proposals/orphan-concepts-2026-05-15.md` § 0.2): `parent_id NOT NULL` chains the new rule off an existing rule in the same proceeding. `trigger_event_id NOT NULL` roots the rule on a paliad/youpc trigger event. The unified Phase 2 schema (Slice 4, mig 081+082) supports both — proposals use `parent_id` whenever the natural anchor is an existing intra-proceeding rule (e.g. `inf.soc` for inf.prelim), which matches the pattern set by `inf.sod`, `inf.def_to_ccr`, etc.
- **`condition_expr` form.** Existing UPC_INF / UPC_REV conditional rules use `{"flag":"with_ccr"}` or `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`. The proposals add three new flag names — `with_po`, `with_appeal`, and reuse `with_amend` only where existing. Flag names are surfaced as **FLAG** items for m to confirm before ingest.
### 0.2 What's deliberately out of scope
- **Order-appeals (R.220.2/R.220.3) spawn wiring** — the brief specifies RoP 220.1(a) (main-judgment, 2-month appeal → `UPC_APP`). The 15-day order/discretion track lives in `UPC_APP_ORDERS` and has its own root rules (`app_ord.with_leave`, `app_ord.discretion`). Spawn rules from UPC_INF/UPC_REV/UPC_PI for that track would be a separate proposal — flagged as future-work in § 6.
- **Cost-decision-appeal spawn (R.221.1)** — `UPC_COST_APPEAL` exists with `cost.leave_app` as a root rule. Same shape as the order-appeals: future-work, not this proposal.
- **R.263 application to amend the case** — surfaced in § 3 but not drafted as a rule because it's court-discretion (no calendar deadline computable from a fixed anchor).
- **Vertagungsantrag (ZPO §227)** — the brief's description of Gap 4 named "Vertagungsantrag" but the Pipeline-A rule code `zpo.vertanz` is actually *Verteidigungsanzeige* (contraction of "Verteidigungs-Anzeige"), not Vertagungsantrag. There is no Vertagungsantrag rule anywhere in the corpus today; if m wants one, that's a fresh proposal. Documented in § 4 FLAG.
---
## 0.3 m's decisions on the open FLAGs (2026-05-18)
Captured live with paliadin/head. Anything not explicitly answered defaults to curie's recommendation.
### Gap 1 — Preliminary Objection
- **F1.4 (CCR-defendant PO):** **NO** — do not seed a third PO rule for the patentee on a CCR. Final shape stays at 2 PO rules: `inf.prelim` + `rev.prelim`.
- F1.1 (flag name): default to curie's `with_po`.
- F1.2 (priority): default to curie's `optional`.
- F1.3 (citation pattern): default to curie's `UPC.RoP.19.1` substantive-cite for both rules (cross-ref to R.46 lives in the description, not the legal_source field).
### Gap 2 — Appeal spawns
- **F2.1 (drop `ccr.appeal`):** **CONFIRMED** — one decision under R.118 = one 2-month appeal window. Rule 2.3 explicitly NOT seeded. Final shape stays at 2 spawn rules.
- **F2.3 (appeal flag-gated or always-fire):** **ALWAYS-FIRE.** Rationale (m): "the appeal deadline should always be triggered by a decision … the flags for ccr / amend are different because that is something which only comes up during the proceedings and depends on a party. Appeal is always a possibility." So both `inf.appeal_spawn` and `rev.appeal_spawn` ship **without `condition_expr`** — the 2-month window unconditionally appears once `inf.decision` / `rev.decision` is anchored. Visibility filtering ("hide appeal deadlines on projects where the user doesn't care") is a frontend concern, not a rule-level flag — surfaced as follow-up (see § 6.X below).
- F2.2 (anchor): default to curie's `parent_id = inf.decision` / `rev.decision` (consistent with how `inf.cost_app` already chains).
### Gap 3 — `ccr.amend` / `rev.amend`
- **F3.1 (model R.263?):** **NO** — court-discretion, no calendar deadline computable. If R.263 ever needs surfacing, it goes on the project page as a checklist item, not the fristenrechner.
### Gap 4 — `zpo.*` family
- **§4.3 — `de_inf.erwidg` discrepancy (6 weeks vs. court-set 2-week minimum):** **FLIP to court-set.** Klageerwiderung is statutorily court-set with a 2-week minimum under ZPO §276(1) S.2; the existing 6-week fixed-duration rule is wrong. Action at ingest: `is_court_set=true`, keep `duration_value=6, duration_unit='weeks'` as the **default display value** when no court order is yet attached, with the description noting "Gericht setzt eine Frist von mindestens zwei Wochen ab Verteidigungsanzeige (§276 Abs. 1 S. 2 ZPO)." This matches the pattern existing court-set rules use elsewhere.
- F4.1 (legal_source backfills on `de_inf.klage` etc.): default to curie's "yes — apply the polish patches in § 4.1, § 4.2, § 4.4".
### Final delta to ingest via `/admin/rules`
```
NEW RULES (4):
inf.prelim UPC_INF parent=inf.soc 1mo RoP.19.1 flag=with_po optional
rev.prelim UPC_REV parent=rev.app 1mo RoP.19.1 flag=with_po optional
inf.appeal_spawn UPC_INF parent=inf.decision 2mo RoP.220.1.a (no flag) optional spawn→merits-appeal
rev.appeal_spawn UPC_REV parent=rev.decision 2mo RoP.220.1.a (no flag) optional spawn→merits-appeal
PATCHES on existing rules:
de_inf.klage set legal_source = 'DE.ZPO.253'
de_inf.anzeige (no change — already correct)
de_inf.erwidg flip is_court_set = true; description note about §276 Abs.1 S.2
de_inf.berufung (verify legal_source — curie's §4.4 polish patch)
```
### Follow-up surfaced — not for this proposal
- **Frontend visibility toggle for appeal deadlines** — m flagged that appeals "always fire" at the rule level but the UI could hide them on projects where the user doesn't want to see them. NOT a rule-corpus question; file as a separate frontend task if/when m signals.
- **`ccr.appeal` in `_archived_litigation`** — the Pipeline-A `ccr.appeal` row stays archived (m's call F2.1). No further action.
- **Vertagungsantrag (ZPO §227)** — never modelled; not in scope. Open follow-up if m wants it.
---
## 1. Gap 1 — Preliminary Objection (RoP 19)
**Status:** Real gap. Pipeline-A had `inf.prelim` (defendant, 1 month, R.19, "Rarely triggers separate decision; usually decided with main case") — archived without a fristenrechner replacement.
Verification — current UPC_INF / UPC_REV corpus has zero rules with `rule_code` matching `R.19`, `RoP.019`, or any "Preliminary Objection" variant; verified via `SELECT * FROM paliad.deadline_rules WHERE rule_code ILIKE '%19%' OR name ILIKE '%vorab%' OR name ILIKE '%prelim%' AND lifecycle_state <> 'archived'` returns empty.
Legal context — RoP 19 itself (Application of the Rules of Procedure, Part 1, Chapter 1, Section 4):
- **R.19.1**: The defendant may, within 1 month of service of the Statement of claim, lodge a Preliminary objection concerning (a) jurisdiction and competence of the Court including any objection to the decision of the Registry to assign a case to a particular division, (b) the language of the Statement of claim (R.14), or (c) the competence of the panel to which the action has been assigned.
- **R.19.7 / R.19.8**: The Court decides on a preliminary objection by way of order, typically before the interim conference, but may join it to the main proceedings.
- **R.46**: The Rules in Part 1, Chapter 1 (including R.19) apply *mutatis mutandis* to revocation actions — i.e. the defendant in a revocation action (the patent proprietor) may also lodge a preliminary objection within 1 month of service of the Statement for revocation.
The Pipeline-A note "Rarely triggers separate decision; usually decided with main case" is accurate practice — but the **1-month deadline to raise the objection** is hard and statutory. That deadline is what the fristenrechner needs to model.
### Rule 1.1 — Preliminary Objection on UPC_INF
- **Rule code:** `inf.prelim`
- **Proceeding type:** UPC_INF (id=8)
- **Name (DE):** Vorab-Einrede (R. 19 VerfO)
- **Name (EN):** Preliminary Objection (RoP 19)
- **Party:** defendant
- **Anchor:** `parent_id = inf.soc` (the existing root rule "Klageerhebung") — same anchor pattern as `inf.sod` (Klageerwiderung, also parent=inf.soc). `inf.soc` is the trigger-date anchor; computing 1 month after `inf.soc` reads as "1 month from service of the Statement of Claim", consistent with R.19.1's wording.
- **Duration:** 1, months
- **Timing:** after
- **Priority:** optional *(party decides whether to raise the objection; the 1-month period is statutory once invoked)*
- **is_court_set:** false *(statutory period from service; not court-set)*
- **condition_expr:** `{"flag":"with_po"}` *(only renders when the defendant indicates a PO will be filed — same shape as existing `with_ccr` / `with_amend` flags)*
- **Legal source:** `UPC.RoP.19.1`
- **`rule_code`:** `RoP.019.1`
- **event_type:** `filing`
- **Notes:** R.19.1 covers three independent grounds (a) jurisdiction/competence, (b) language under R.14, (c) panel competence. All share the same 1-month deadline. The UI rendering decision (one row vs. three rows by ground) is downstream UX, not a rule-corpus question.
- **FLAG (F1.1):** Flag name — `with_po` is suggested by analogy to `with_ccr` / `with_amend` / `with_cci`. Alternative names: `with_preliminary_objection`, `prelim`. m's call.
- **FLAG (F1.2):** Priority — proposed `optional` (defendant chooses); m may prefer `recommended` to surface it as a sanity-check chip on every defendant timeline. The Pipeline-A predecessor had `is_optional=true / is_mandatory=false` per the old binary schema, which maps cleanly to `priority='optional'` in the post-Slice-3 enum.
### Rule 1.2 — Preliminary Objection on UPC_REV
- **Rule code:** `rev.prelim`
- **Proceeding type:** UPC_REV (id=9)
- **Name (DE):** Vorab-Einrede (R. 19 i.V.m. R. 46 VerfO)
- **Name (EN):** Preliminary Objection (RoP 19 in conjunction with RoP 46)
- **Party:** defendant *(in a revocation action the patentee is the defendant)*
- **Anchor:** `parent_id = rev.app` (the existing root rule "Nichtigkeitsklage" — analogous to `rev.defence` which also parents off `rev.app`)
- **Duration:** 1, months
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** `{"flag":"with_po"}` *(same flag as 1.1 — a PO is a PO; the user sets `with_po=true` on a UPC_REV project when the patentee plans to lodge one)*
- **Legal source:** `UPC.RoP.46` *(R.46 makes R.19 applicable to revocation actions; cite R.46 as the operative provision because RoP 19's literal text only addresses infringement)*
- **`rule_code`:** `RoP.046` *(or `RoP.019.1` with a note — m's call; see FLAG F1.3)*
- **event_type:** `filing`
- **Notes:** Functionally identical to Rule 1.1 but rooted on UPC_REV. The grounds are narrower in practice (language and panel competence are the main triggers — jurisdiction is rarely contested in pure revocation actions because the UPC's jurisdiction over revocation of unitary patents is exclusive). But the 1-month statutory window is identical.
- **FLAG (F1.3):** Legal-source citation — should this read `UPC.RoP.46` (operative provision for revocation) or `UPC.RoP.19.1` (substantive content)? Existing rules use the substantive citation (e.g. `inf.def_to_ccr` cites `UPC.RoP.29.a`, not the cross-reference that brings R.29 into the UPC_INF flow). I lean `UPC.RoP.19.1` with `rule_code='RoP.019.1'` to match that pattern; the cross-reference to R.46 belongs in the description, not the citation field.
- **FLAG (F1.4):** Does paliad want **counterclaim-defendant** PO rules too? Specifically, when UPC_INF has `with_ccr=true`, the *claimant* (patentee) becomes the de-facto-defendant for the CCR portion. Does the claimant get a 1-month PO window from service of the CCR? My read of R.19 + R.46 + R.25: yes — the CCR triggers a fresh R.19 window for the claimant, anchored on service of the SoD-with-CCR. But this would be a third rule (`inf.prelim_ccr`, party=claimant, parent=inf.sod, 1 month, condition_expr={"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_po_ccr"}]}). I'm **not** drafting it pending m's confirmation; either it's truly there in the case law or it's an over-reading on my part. Lex-research won't help here because there's no relevant published UPC PO case on a CCR yet (R.46 + R.25 cross-reads are theoretical).
**Summary for Gap 1:** 2 new rules drafted (one on UPC_INF, one on UPC_REV). 4 FLAGs. Potential third rule (CCR-PO) deferred pending m's read.
---
## 2. Gap 2 — Cross-proceeding APP spawns (RoP 220.1(a))
**Status:** Real gap. Pipeline-A had three placeholder rules (`inf.appeal`, `rev.appeal`, `ccr.appeal`, all 2 months, RoP.220.1, is_spawn=true) — but their `spawn_proceeding_type_id` was NULL so they weren't functional spawns either. Fristenrechner UPC_APP currently starts standalone with `app.notice` as its root rule (party=both, 2 months, RoP.220.1).
Verification — current corpus has zero `is_spawn=true AND is_active=true AND lifecycle_state<>'archived'` rules; the `spawn_proceeding_type_id` column on `paliad.deadline_rules` is unused in the live data (Slice 7 wiring was the design intent but no real spawns have been seeded yet).
Legal context — RoP 220 (Decisions and orders which may be appealed):
- **R.220.1(a)**: Final decisions under R.118 may be appealed. The appeal period is **2 months of service** of the decision (R.224.1(a)).
- **R.224.1(a)**: The Statement of appeal must be lodged within 2 months of service of the decision.
- **R.224.2(a)**: The Statement of grounds of appeal must be lodged within 4 months of service of the decision (independent from R.224.1(a), not chained off it).
The spawn target — the proceeding rooted by `app.notice` (Berufungseinlegung, RoP.220.1, 2 months) and `app.grounds` (Berufungsbegründung, 4 months from decision) — is what the task brief calls the "UPC infringement-appeal (RoP 220.1(a) main-judgment appeal)" proceeding. Today that's `UPC_APP` (id=11); per t-paliad-204, the code may be renamed before m ingests these proposals, so I refer to it by role only.
### Rule 2.1 — Appeal spawn from UPC_INF
- **Rule code:** `inf.appeal_spawn`
- **Proceeding type:** UPC_INF (id=8)
- **Name (DE):** Berufung gegen Endentscheidung
- **Name (EN):** Appeal against final decision
- **Party:** both *(either party may appeal an R.118 final decision adverse to them)*
- **Anchor:** `parent_id = inf.decision` (existing court-set rule "Entscheidung"). The chain: `inf.soc → … → inf.decision (court-set, no statutory date) → inf.appeal_spawn (2 months after service of decision)`. Because `inf.decision` is `IsCourtSet=true` (per `isCourtDeterminedRule` in `internal/services/fristenrechner.go`), the appeal-spawn deadline only becomes a concrete date once the user anchors `inf.decision` via the smart-timeline click-to-anchor mechanism (Slice 2, `POST /api/projects/{id}/timeline/anchor` per memory `ab966313-cae6-49b0-8223-9adb62a64370`).
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional *(party decides whether to appeal; the 2-month period is statutory once invoked)*
- **is_court_set:** false *(deadline is statutory once the decision is served)*
- **condition_expr:** `{"flag":"with_appeal"}` *(only renders when the user has indicated an appeal is contemplated — keeps non-appealing projects' timelines clean)*
- **Legal source:** `UPC.RoP.220.1`
- **`rule_code`:** `RoP.220.1.a`
- **event_type:** `filing`
- **is_spawn:** true
- **spawn_proceeding_type_id:** → UPC infringement-appeal proceeding (currently `UPC_APP`, id=11; m picks final code at ingest per t-paliad-204).
- **spawn_label (DE):** "Berufungsverfahren öffnen"
- **spawn_label (EN):** "Open appeal proceedings"
- **Notes:** Spawning into the appeal proceeding creates a child project (or routes into the standalone UPC_APP fristenrechner depending on how spawn rendering works on the project page). The 4-month Statement of grounds period (R.224.2(a), `app.grounds`) is already a root rule on UPC_APP — once the appeal child opens, that timeline takes over. **No need** to also model `app.grounds` as a spawn rule from UPC_INF; the existing UPC_APP root rules cover it.
- **FLAG (F2.1):** Does the spawn fire on the CCR portion of the decision too? In a `with_ccr=true` UPC_INF, the R.118 final decision adjudicates both the infringement *and* the counterclaim for revocation. Either side may appeal either part. My read: **one spawn covers both** — there's only one R.118 decision, one 2-month window. The Pipeline-A `ccr.appeal` was a relic of the days when CCR was a separate proceeding type. **Recommend dropping the third "ccr.appeal" entirely**, because in the unified UPC_INF (CCR-as-flag) model it would duplicate Rule 2.1. m to confirm.
- **FLAG (F2.2):** Anchor — should the spawn rule chain off `inf.decision` (court-set, requires anchor-click) or be event-rooted on a `final_decision_service` trigger event (paliad has trigger_event id=88 "Endentscheidung (Zustellung)")? Both work. Chaining on `inf.decision` keeps the rule visually attached to its parent proceeding in the UI; event-rooted is more flexible if the user wants to compute an appeal deadline standalone without a project. Recommend `parent_id = inf.decision` to match how `inf.cost_app` chains off `inf.decision` already.
- **FLAG (F2.3):** Flag name — `with_appeal` mirrors the existing `with_ccr` / `with_amend` flag naming. Alternative: spawn rules might always fire (no flag), letting the timeline show the appeal window as a "predicted/court-set" placeholder. The latter is closer to what the SmartTimeline projection (`projection_service.go`) already does for cross-proceeding rules per memory `686f0b8c-02ed-4807-8785-b088e3a3e515` § 6 gap 7. If m wants the appeal window to *always* appear after the decision (unconditionally), drop `condition_expr` here and on Rule 2.2.
### Rule 2.2 — Appeal spawn from UPC_REV
- **Rule code:** `rev.appeal_spawn`
- **Proceeding type:** UPC_REV (id=9)
- **Name (DE):** Berufung gegen Endentscheidung (Nichtigkeit)
- **Name (EN):** Appeal against final decision (revocation)
- **Party:** both
- **Anchor:** `parent_id = rev.decision` (existing court-set rule "Entscheidung")
- **Duration:** 2, months
- **Timing:** after
- **Priority:** optional
- **is_court_set:** false
- **condition_expr:** `{"flag":"with_appeal"}`
- **Legal source:** `UPC.RoP.220.1`
- **`rule_code`:** `RoP.220.1.a`
- **event_type:** `filing`
- **is_spawn:** true
- **spawn_proceeding_type_id:** → same UPC infringement-appeal proceeding as Rule 2.1. The UPC CoA hears both INF and REV appeals; in a `with_cci=true` UPC_REV (Verletzungswiderklage / counterclaim-for-infringement), the R.118 decision may also adjudicate the infringement piece, but again it's one decision, one appeal window.
- **spawn_label (DE):** "Berufungsverfahren öffnen"
- **spawn_label (EN):** "Open appeal proceedings"
- **Notes:** Functionally a mirror of Rule 2.1 on the revocation proceeding. Same FLAGs F2.1-F2.3 apply.
### Rule 2.3 — (proposed) NOT drafted: separate `ccr.appeal` from UPC_INF with_ccr
**See FLAG F2.1.** In the unified model, the CCR portion of an UPC_INF decision is appealed via the same R.118 final-decision spawn (Rule 2.1) — a single 2-month window covers infringement, revocation, and patent-amendment claims because they all sit in one R.118 decision. Drafting `ccr.appeal` as a third rule would duplicate Rule 2.1 conditionally (`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_appeal"}]}`) and produce a redundant timeline row. **Recommendation: do not seed.** If m disagrees, the rule shape would be:
```
inf.appeal_spawn_ccr (UPC_INF)
condition_expr: {"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_appeal"}]}
spawn_label: "Berufung Nichtigkeit öffnen" (specifically the CCR portion)
```
Only useful if the appeal UI needs to distinguish "appealing the infringement finding" from "appealing the revocation finding". Today's fristenrechner UI doesn't make that distinction; the appeal proceeding handles both.
**Summary for Gap 2:** 2 new spawn rules drafted. 3 FLAGs. The third Pipeline-A relic (`ccr.appeal`) is structurally redundant and recommended **not** to seed.
---
## 3. Gap 3 — `ccr.amend` / `rev.amend` (verification of "safe to drop" claim)
**Status:** No new rules needed. The migration's claim ("superseded by `inf.app_to_amend` / `rev.app_to_amend` — safe to drop") is **confirmed for the patent-amendment scope**. There is a separate concept (R.263 application to amend the case) that has never been modelled and probably shouldn't be — see § 3.2.
### 3.1 Verification — patent-amendment coverage
Pipeline-A's `ccr.amend` and `rev.amend` were both:
- duration_value=0, duration_unit='months', event_type='filing', is_spawn=true, party='claimant'
- legal_source=NULL, rule_code=NULL
- source proceeding=AMD (now archived)
- "Application to Amend Patent" / no German name
These were placeholder spawns into a hypothetical "AMD" (Application to amend the patent) proceeding type that never existed as a real fristenrechner tree. They modelled the concept "filing a patent amendment", not its deadline.
The unified UPC_INF / UPC_REV corpus already covers patent amendment with real deadlines and flag-gated chains:
| Existing rule | Proceeding | Trigger / parent | Duration | Legal source | Flag-gating |
|---|---|---|---|---|---|
| `inf.app_to_amend` | UPC_INF | parent=inf.sod | 2 months | UPC.RoP.30.1 | `with_ccr+with_amend` |
| `inf.def_to_amend` | UPC_INF | parent=inf.app_to_amend | 2 months | UPC.RoP.32.1 | `with_ccr+with_amend` |
| `inf.reply_def_amd` | UPC_INF | parent=inf.def_to_amend | 1 month | UPC.RoP.32.3 | `with_ccr+with_amend` |
| `inf.rejoin_amd` | UPC_INF | parent=inf.reply_def_amd | 1 month | UPC.RoP.32.3 | `with_ccr+with_amend` |
| `rev.app_to_amend` | UPC_REV | parent=rev.defence | 0 months (filed-with-parent) | UPC.RoP.49.2.a | `with_amend` |
| `rev.def_to_amend` | UPC_REV | parent=rev.app_to_amend | 2 months | UPC.RoP.43.3 | `with_amend` |
| `rev.reply_def_amd` | UPC_REV | parent=rev.def_to_amend | 1 month | UPC.RoP.32.3 | `with_amend` |
| `rev.rejoin_amd` | UPC_REV | parent=rev.reply_def_amd | 1 month | UPC.RoP.32.3 | `with_amend` |
The flag-gated chain on UPC_INF (`with_ccr+with_amend`) is the post-2026-05-05 ship from t-paliad-131 PR-2 (memory `ba1517a3-2294-4c58-aeb6-87e82067834d`); the UPC_REV chain (`with_amend` and `with_cci`) is from the same PR. Both fully replace what `ccr.amend` / `rev.amend` ever could have represented.
**Verdict on Gap 3:** "Safe to drop" is correct. **No new rules.**
### 3.2 R.263 — Application to amend the case (not modelled, probably shouldn't be)
R.263 ("Leave to change claim or amend case") is conceptually different from R.30 (Application to amend the patent). R.263 governs amendment of the **pleadings / case** — adding a new infringement allegation, narrowing claims, etc. The current corpus has no R.263 rule.
I'm **not proposing one** because R.263 is purely court-discretionary (R.263.1: "An application may be made by a party at any time to … amend its case … Leave shall be granted only if … the requesting party could not with reasonable diligence have made the application earlier and the amendment will not unreasonably hinder the other party in the conduct of its action"). There is no statutory deadline computable from a fixed anchor — the party files when it needs to, and the court grants or refuses leave by order. Modelling it as a deadline_rule would either:
- (a) Produce a phantom row with no computable date (the existing `is_court_set=true` pattern would technically work but offers no UX value because the deadline is "whenever you need to amend").
- (b) Produce a misleading row anchored on the SoC date with some heuristic period.
**Recommendation: don't seed.** If m wants R.263 surfaced anywhere, it belongs as a checklist item on the project page, not as a fristenrechner rule.
**FLAG (F3.1):** Confirm "don't model R.263" is acceptable. If R.263 *should* be modelled, what anchor + duration heuristic should it use?
**Summary for Gap 3:** 0 new rules. 1 FLAG. The claim "safe to drop" is verified for patent amendment. R.263 is a separate concept and intentionally left unmodelled.
---
## 4. Gap 4 — `zpo.*` family vs. existing DE_INF / DE_INF_OLG / DE_INF_BGH
**Status:** No new rules needed for `klage`, `vertanz`, `berufung`. **Existing rule `de_inf.erwidg` (Klageerwiderung) has a duration discrepancy worth m's attention.** Task brief's mention of "Klageerweiterung" / "Vertagungsantrag" is a misread of Pipeline-A rule names — those concepts are not in scope here. § 4.1-4.4 verify each Pipeline-A rule; § 4.5 surfaces what *would* be a real gap if m wants ZPO §227 modelled.
### 4.1 `zpo.klage` (Klageerhebung, ZPO §253) — ✓ redundant
Pipeline-A: claimant, 0 months, filing, `§ 253 ZPO`, legal_source=NULL.
Existing rule `de_inf.klage` on DE_INF: claimant, 0 months, filing. Functionally identical as a root rule (a 0-duration "trigger" anchor). Legal source on the existing rule is NULL — could be backfilled to `DE.ZPO.253` as a minor polish, but no new rule needed.
**Verdict: no gap.** *Optional polish:* set `de_inf.klage.legal_source = 'DE.ZPO.253'` (one-line UPDATE; not a new rule). FLAG F4.1.
### 4.2 `zpo.vertanz` (Verteidigungsanzeige, ZPO §276(1) Satz 1) — ✓ redundant
**Task-brief naming note:** the brief described this gap as "Vertagungsantrag" but Pipeline-A's `zpo.vertanz` is actually *Verteidigungsanzeige* (contraction "VertAnz" not "VertA. (Antrag)"). The rule name in the snapshot reads "Verteidigungsanzeige" verbatim. Vertagungsantrag (§ 227 ZPO) is a different concept entirely — see § 4.5.
Pipeline-A: defendant, 2 weeks, filing, `§ 276 Abs. 1 S. 1 ZPO`, deadline_notes "Notfrist ab Zustellung der Klageschrift".
Existing rule `de_inf.anzeige` on DE_INF: defendant, 2 weeks, `DE.ZPO.276.1`, "Anzeige der Verteidigungsbereitschaft". Same period, same legal basis, same party.
**Verdict: no gap.**
### 4.3 `zpo.klageerw` (Klageerwiderung, ZPO §276(1) Satz 2) — ⚠ duration discrepancy
Pipeline-A: defendant, **2 weeks**, filing, `§ 276 Abs. 1 S. 2 ZPO`, legal_source=NULL, deadline_notes "Vom Gericht gesetzt, mindestens 2 Wochen".
Existing rule `de_inf.erwidg` on DE_INF: defendant, **6 weeks**, `DE.ZPO.276.1`, "Klageerwiderung", is_court_set=false.
**This is a substantive discrepancy.** Both rules cite the same statutory anchor (ZPO §276(1) Satz 2), but:
- Pipeline-A modelled the **statutory floor** ("mindestens 2 Wochen") with `is_court_set` implicit (the deadline_notes said "Vom Gericht gesetzt").
- DE_INF models a **typical court-practice heuristic** (6 weeks is a common Munich/Düsseldorf LG setting, though 4-8 weeks is the realistic range).
The DE_INF rule is **strictly more useful** for a practitioner planning a defence schedule (the 2-week floor is rarely the actual deadline; the court order sets the real date). But it's **technically wrong** to mark `is_court_set=false` because the date *is* set by court order — the 6 weeks is a guess at what the court will set, not a statutory period.
**No new rule needed**, but two corrections are worth flagging on the existing rule:
- **FLAG F4.2 (correctness):** Set `de_inf.erwidg.is_court_set = true`. The deadline date is set by the court's Klageerwiderungsfrist order under §276(1) Satz 2, not by the statute directly. This matches how Schriftsatznachreichung (§296a) was flagged in `docs/proposals/orphan-concepts-2026-05-15.md` § 2.1 FLAG F8.
- **FLAG F4.3 (heuristic transparency):** 6 weeks vs. the statutory 2-week floor — the deadline_notes (DE) on `de_inf.erwidg` should probably say "Vom Gericht gesetzt, mindestens 2 Wochen (§ 276 Abs. 1 S. 2 ZPO); typische Praxis: 4-8 Wochen" rather than just rendering as a hard 6-week deadline. UX consideration, not a rule-shape question.
Neither change is a new rule; both are PATCH operations on the existing row via `/admin/rules`.
### 4.4 `zpo.berufung` (Berufung, ZPO §517) — ✓ redundant (twice over)
Pipeline-A: both, 1 month, filing, `§ 517 ZPO`, `DE.ZPO.517`, deadline_notes "Notfrist ab Zustellung des vollständigen Urteils".
Existing rules:
- `de_inf.berufung` on DE_INF: both, 1 month, `DE.ZPO.517`. Same shape.
- `de_inf_olg.berufung` on DE_INF_OLG: both, 1 month, `DE.ZPO.517`. Same shape (covers the OLG-instance entry point).
Either rule covers it. **Verdict: no gap.**
### 4.5 Real gap (if m wants): Vertagungsantrag (ZPO §227)
The task brief mentioned "Vertagungsantrag" by name. Pipeline-A had no Vertagungsantrag rule (the `zpo.vertanz` rule code is a contraction of *Verteidigungsanzeige*, not Vertagungsantrag — see § 4.2). The current corpus has no Vertagungsantrag rule either.
ZPO §227 governs applications to adjourn a hearing ("Aufhebung und Verlegung von Terminen, Vertagung der Verhandlung"). §227.1 requires "erhebliche Gründe", §227.2 gives examples (verhinderter Anwalt etc.), §227.3 restricts adjournment of evidence hearings (Beweisaufnahme). **There is no statutory deadline for filing a Vertagungsantrag** — it's "as soon as the ground arises and, in practice, as early as possible before the hearing date". The application is court-discretionary (§227.1: "kann").
I would **not** recommend modelling Vertagungsantrag as a deadline_rule for the same reason as R.263 in § 3.2: there's no statutory deadline anchor; it's a checklist concept, not a calendar deadline. But m may have a different view — flag F4.4.
**FLAG (F4.4):** Should Vertagungsantrag be modelled? If yes, what anchor + duration? Most natural seed would be `condition_expr={"flag":"with_vertagung"}` on the relevant hearing rule (de_inf.termin, de_null.termin, etc.), is_court_set=true, no duration. But that's an oddly-shaped rule that produces no useful date.
**Summary for Gap 4:** 0 new rules. 4 FLAGs (F4.1-F4.4). The migration's "redundant — safe to drop" claim is confirmed for `klage` / `vertanz` / `berufung`. `klageerw` exposes a discrepancy on the existing `de_inf.erwidg` rule (`is_court_set=false` is wrong; 6-weeks heuristic should be transparent in notes) — both are PATCH operations on the existing row, not new rules. Vertagungsantrag is a separate concept that probably shouldn't be modelled as a deadline_rule.
---
## 5. Track A — Polish UPDATEs on existing rows (no new rules, no legal review)
Distinct from new rules, three existing rows could be PATCH'd via `/admin/rules` to improve correctness or transparency. **None of these are required for the gap-fill to be considered "done"** — they're flagged so they don't get lost if m wants to address them in the same ingest session.
| # | Row | Field | From | To | Reason |
|---|---|---|---|---|---|
| P1 | `de_inf.klage` (DE_INF) | `legal_source` | NULL | `DE.ZPO.253` | Polish; matches existing convention (Rule 1.1's `UPC.RoP.19.1` etc.). |
| P2 | `de_inf.erwidg` (DE_INF) | `is_court_set` | false | true | Correctness; deadline is court-order-set per ZPO §276(1) Satz 2. |
| P3 | `de_inf.erwidg` (DE_INF) | `deadline_notes` (DE) | (current text) | "Vom Gericht gesetzt, mindestens 2 Wochen (§ 276 Abs. 1 S. 2 ZPO); typische Praxis: 4-8 Wochen" | Transparency; the 6-week duration is a heuristic, not statutory. |
---
## 6. Track B — Genuinely new rule drafts (this proposal's substantive output)
| # | Gap | Rule code | Proceeding (by role) | Source |
|---|---|---|---|---|
| 1.1 | 1 (PO) | `inf.prelim` | UPC_INF | RoP 19.1 |
| 1.2 | 1 (PO) | `rev.prelim` | UPC_REV | RoP 19.1 i.V.m. R.46 |
| 2.1 | 2 (APP spawn) | `inf.appeal_spawn` | UPC_INF, spawn → UPC infringement-appeal proceeding | RoP 220.1(a) / R.224.1(a) |
| 2.2 | 2 (APP spawn) | `rev.appeal_spawn` | UPC_REV, spawn → UPC infringement-appeal proceeding | RoP 220.1(a) / R.224.1(a) |
**Total new rules: 4.** Plus 3 optional polish PATCHes in § 5. None of the proposed rules introduce new flag-name conventions (other than `with_po` and `with_appeal`, which mirror existing `with_ccr` / `with_amend` / `with_cci`).
### Future-work (not this proposal)
- Order-appeals spawn (R.220.2 / R.220.3) from UPC_INF / UPC_REV / UPC_PI → UPC_APP_ORDERS (15-day track). Today UPC_APP_ORDERS has only standalone root rules.
- Cost-decision-appeal spawn (R.221.1) from UPC_INF / UPC_REV → UPC_COST_APPEAL.
- CCR-defendant PO (FLAG F1.4): claimant's 1-month PO window when receiving SoD-with-CCR — only if confirmed against real case law or m's read.
- R.263 (case amendment) and ZPO §227 (Vertagungsantrag): both court-discretionary, no statutory deadline — recommend leaving unmodelled (FLAGs F3.1, F4.4).
- DE_NULL / DE_NULL_BGH appeal spawns: PatG §110 chains DE_NULL → DE_NULL_BGH (Berufung BGH). Currently DE_NULL_BGH is a standalone tree rooted on `de_null_bgh.urteil_bpatg`. Same pattern as the UPC spawn gap. Out of brief scope but worth a parallel proposal.
---
## 7. Open questions / FLAGs index
For convenience, all `**FLAG**`-marked items in one place. m's decision is needed on each before `/admin/rules` ingest of the corresponding rule (or rule edit).
| ID | Section | Question |
|---|---|---|
| F1.1 | § 1.1 | Flag name for Preliminary Objection — `with_po` vs `with_preliminary_objection` vs `prelim`. |
| F1.2 | § 1.1 | Priority for PO — `optional` (recommended) vs `recommended` (always-surface as sanity-check chip). |
| F1.3 | § 1.2 | Legal-source citation for UPC_REV PO — `UPC.RoP.19.1` (substantive) vs `UPC.RoP.46` (operative). Recommend substantive. |
| F1.4 | § 1.2 | Add a third PO rule for CCR-defendant (party=claimant, fires when `with_ccr=true`)? |
| F2.1 | § 2.1 | Recommend **not seeding** `ccr.appeal` as a third rule — CCR appeal is covered by `inf.appeal_spawn` (one R.118 decision, one window). Confirm. |
| F2.2 | § 2.1 | Anchor for spawn — `parent_id = inf.decision` (chain) vs `trigger_event_id = 88 final_decision_service` (event-rooted). Recommend chain. |
| F2.3 | § 2.1 | Flag-gated (`with_appeal`) vs always-rendered. Recommend flag-gated to keep non-appealing timelines clean; SmartTimeline's "predicted" rendering of cross-proceeding rules is the alternative. |
| F3.1 | § 3.2 | R.263 (case amendment) — confirm not modelled as a deadline_rule. |
| F4.1 | § 4.1 | Polish P1: backfill `de_inf.klage.legal_source = 'DE.ZPO.253'`? |
| F4.2 | § 4.3 | Polish P2: set `de_inf.erwidg.is_court_set = true`? |
| F4.3 | § 4.3 | Polish P3: improve `de_inf.erwidg.deadline_notes` to expose the 6-week heuristic vs the 2-week statutory floor? |
| F4.4 | § 4.5 | Vertagungsantrag (ZPO §227) — confirm not modelled. |
---
## 8. Sources cited
| Citation key | Reference |
|---|---|
| `UPC.RoP.19.1` | UPC Rules of Procedure, Rule 19(1) — Preliminary objection |
| `UPC.RoP.19.7` | UPC RoP Rule 19(7) — Court decides preliminary objection by order |
| `UPC.RoP.25` | UPC RoP Rule 25 — Lodging of Counterclaim for Revocation (cross-ref for FLAG F1.4) |
| `UPC.RoP.30.1` | UPC RoP Rule 30(1) — Application to amend the patent (cross-ref for § 3.1) |
| `UPC.RoP.46` | UPC RoP Rule 46 — Part 1 Chapter 1 (incl. R.19) applies *mutatis mutandis* to revocation actions |
| `UPC.RoP.118` | UPC RoP Rule 118 — Final decisions on the merits |
| `UPC.RoP.151` | UPC RoP Rule 151 — Cost decision (cross-ref for existing `inf.cost_app`) |
| `UPC.RoP.220.1.a` | UPC RoP Rule 220(1)(a) — Appeal against R.118 final decision |
| `UPC.RoP.220.2` | UPC RoP Rule 220(2) — Order appeals with leave (cross-ref, future work) |
| `UPC.RoP.220.3` | UPC RoP Rule 220(3) — Discretionary review (cross-ref, future work) |
| `UPC.RoP.221.1` | UPC RoP Rule 221(1) — Cost-decision appeal (cross-ref, future work) |
| `UPC.RoP.224.1.a` | UPC RoP Rule 224(1)(a) — Statement of appeal lodged within 2 months |
| `UPC.RoP.224.2.a` | UPC RoP Rule 224(2)(a) — Statement of grounds within 4 months |
| `UPC.RoP.263` | UPC RoP Rule 263 — Leave to change claim or amend case |
| `DE.ZPO.227` | ZPO §227 — Vertagung und Terminsänderung |
| `DE.ZPO.253` | ZPO §253 — Klageschrift |
| `DE.ZPO.276.1` | ZPO §276(1) — Verteidigungsanzeige (S.1) und Klageerwiderungsfrist (S.2) |
| `DE.ZPO.517` | ZPO §517 — Berufungsfrist (1 Monat ab Zustellung) |
---
## 9. What's next (if m approves)
1. **Decide the 12 FLAGs in § 7** (mostly flag names, priorities, and the three PATCH operations on existing rows). None require legal-side research — they're product/UX calls.
2. **Confirm the appeal target's final proceeding-code** post-t-paliad-204 rename. Until then, ingest using whatever code lives at id=11 (currently `UPC_APP`) and rename via mig if t-paliad-204 lands with a different code.
3. **Ingest the 4 new rules** via `/admin/rules` POST (Slice 11a backend, Slice 11b frontend). Each goes into `lifecycle_state='draft'` first. Promote to `published` after spot-checking via the calculator preview endpoint with a test project (e.g. UPC_INF with `with_po=true` should show the new `inf.prelim` row 1 month after the trigger date).
4. **Optionally apply the 3 PATCHes in § 5** in the same session.
5. **Verify spawn rendering** end-to-end — the spawn_proceeding_type_id column is unused in live data today, so this is the first real consumer. The SmartTimeline projection (per `internal/services/projection_service.go`, memory `686f0b8c-…`) early-returns on spawn rules when "we don't have that rule in our map" — that code path needs to actually render a spawn row now, not no-op. May require a Slice 7 follow-up tweak in `projection_service.go` to honour `spawn_proceeding_type_id` and surface the appeal proceeding's root deadline as a spawned child row.
**Estimated corpus delta after ingest:** Track B = 4 new rules → `paliad.deadline_rules` row count grows from 249 to **253**. Track A polish = 3 row-level PATCHes (no row count change). One new `is_spawn=true` row goes live for the first time, exercising the previously-unused `spawn_proceeding_type_id` wiring.

View File

@@ -0,0 +1,429 @@
# Legal-citation Backfill Proposals — t-paliad-208 (Workstream A)
**Date:** 2026-05-18
**Author:** huygens (researcher)
**Status:** DRAFT — for m's review, not yet migrated
**Branch:** `mai/huygens/workstream-a-backfill`
**Adjacent:** parallel-track with t-paliad-209 (workstream B — `code` rename + UI cleanup; different fields, no overlap)
**Successor:** mig 097 will UPDATE the rows m approves; backup snapshot `deadline_rules_pre_097`
---
## 0. Read-this-first
### 0.1 What this doc is
Today's audit (paliadin/head, 2026-05-18) found that **130 of 213 active+published rows in `paliad.deadline_rules`** have `rule_code IS NULL`, and 122 have `legal_source IS NULL`. The internal slug field `code` (e.g. `inf.sod`, `de_null.berufung`) had been mistaken for a legal citation; it is just the per-proceeding submission identifier. The actual RoP / ZPO / EPÜ / PatG / UPCA citation belongs in `rule_code` (display form) + `legal_source` (structured locator).
This document proposes a citation per rule. m approves; head re-tasks for migration 097.
### 0.2 Field convention (profiled from the 83 already-populated rows)
| Field | Purpose | Examples from live data |
|---|---|---|
| `rule_code` | **Human display form**, what we'd write in a brief | `§ 276 ZPO`, `§ 110 PatG`, `Art. 99 EPÜ`, `R. 71(3) EPÜ`, `R. 116 EPÜ`, `RPBA Art. 12`, `RoP.029.a`, `RoP.220.1.a`, `RoP.151`, `RoP.49.1` |
| `legal_source` | **Structured locator** (forum-prefixed, no zero padding) for cross-system joins / lex extraction | `DE.ZPO.276.1`, `DE.PatG.111.1`, `EU.EPÜ.108`, `EU.EPC-R.71.3`, `EU.RPBA.12.1.c`, `UPC.RoP.29.a`, `UPC.RoP.220.1` |
**Sub-conventions observed in live data**
- `legal_source` prefixes: `DE.<statute>.<n>.<para>`, `EU.EPÜ.<n>.<para>`, `EU.EPC-R.<n>.<para>`, `EU.RPBA.<n>.<para>.<letter>`, `UPC.RoP.<n>.<sub>`.
- `rule_code` padding for UPC RoP is **inconsistent today**: rules below 100 are mostly 3-digit padded (`RoP.029.a`, `RoP.030.1`, `RoP.049.2.a`, `RoP.056.1`) but `rev.defence` carries an un-padded `RoP.49.1`. Rules ≥100 are never padded (`RoP.137.2`, `RoP.220.1`).
- **Proposed normalization:** 3-digit pad for rules <100, no pad for 100. mig 097 should also normalize `RoP.49.1 → RoP.049.1` (1 outlier row, `rev.defence`) as a side-fix. m to confirm.
- `legal_source` for UPC RoP **never** pads (`UPC.RoP.29.a`, not `UPC.RoP.029.a`). I follow that.
### 0.3 Triage philosophy — events vs. deadlines
Of the 130 NULL-rule_code rows, 53 carry a `proceeding_type_id` and 77 are orphans (`proceeding_type_id IS NULL`, also `code IS NULL`). Within the proceeding-typed bucket, most are **event markers** (zero `duration_value`, `event_type ∈ {hearing, decision, filing}`) that anchor other deadlines rather than computing one of their own.
I classify each row as one of:
| Category | Treatment | Examples |
|---|---|---|
| **Deadline** (positive duration, fires off an anchor) | Cite the operative procedural norm. Confidence usually HIGH. | `inf.sod` Klageerwiderung 3 months RoP.23 |
| **Constitutive event** (zero duration, but a statute defines it) | Cite the constitutive norm (matches existing convention: `de_inf.klage` already has `DE.ZPO.253`). Confidence HIGH where the norm is canonical. | Klageerhebung § 253 ZPO; Anmeldung EP Art. 75 EPÜ; Klage UPC RoP.13.1 |
| **Service / trigger event** (zero duration, third-party delivery) | Cite the service norm 317 ZPO etc.) with MEDIUM confidence these are anchor events for downstream timers, not deadlines on a party. m may prefer NULL here. **FLAG.** | `de_inf_olg.urteil_lg` Zustellung LG-Urteil |
| **Court-scheduled event** (hearing, judgment-issuance) | Either NULL (recommended) or cite the general norm authorising the court to schedule. **FLAG.** | Mündliche Verhandlung BGH; OLG-Urteil |
| **Court-set duration** (positive duration but `is_court_set=true`, or local practice) | Cite the framing norm (e.g. § 273 ZPO for ZPO patent practice), MEDIUM, FLAG. | `de_inf.replik` 4 weeks (LG patent practice) |
**Where I am proposing NULL**, the row stays as-is on the DB side (mig 097 simply doesn't touch it). The FLAG list at the bottom of this doc enumerates every NULL proposal so m can override with an explicit citation if desired.
### 0.4 Counts
- 130 rows in scope (rule_code IS NULL; is_active=true; lifecycle_state='published')
- 53 proceeding-typed + 77 orphan (no proceeding_type_id, no code)
- 8 rows already carry a `legal_source` those are **easy wins**: only `rule_code` needs proposing
- ~ 40 HIGH-confidence proposals
- ~ 35 MEDIUM-confidence proposals
- ~ 55 FLAG entries (court-scheduled events, combined-pleading rows, ambiguous orphans)
The orphan bucket carries a noticeable number of **duplicates** (six "Mängelbeseitigung / Zahlung" rows, two "Beginn des Hauptsacheverfahrens", two "Antrag auf Patentänderung", etc.). Those are likely vestiges of older Fristenrechner pipelines; backfilling them with the same citation is fine, but m may want a separate dedup pass (out of scope here; flag in § 4).
---
## 1. Easy wins — rows with `legal_source` already set, `rule_code` missing (8)
For these, the structured locator is already in the DB; only the display form is missing.
| id | code / name | duration | existing `legal_source` | proposed `rule_code` | conf |
|---|---|---|---|---|---|
| `1f532c82…` | `de_inf.klage` / Klageerhebung | event | `DE.ZPO.253` | `§ 253 ZPO` | HIGH |
| `20254f4e…` | (orphan) Einspruch gegen Versäumnisurteil | 2 weeks | `DE.ZPO.339.1` | `§ 339 ZPO` | HIGH |
| `3c36f149…` | (orphan) Schriftsatznachreichung 296a ZPO) | 3 weeks | `DE.ZPO.296a` | `§ 296a ZPO` | HIGH |
| `f1099cf6…` | (orphan) Weiterbehandlungsantrag (Art. 121 EPÜ) | 2 months | `EU.EPC-R.135.1` | `R. 135 EPÜ` | HIGH |
| `c24d494c…` | (orphan) Wiedereinsetzungsantrag 123 PatG) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
| `d40d9be7…` | (orphan) Wiedereinsetzungsantrag 233 ZPO) | 2 weeks | `DE.ZPO.234.1` | `§ 234 ZPO` | HIGH |
| `23c6f445…` | (orphan) Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 months | `EU.EPC-R.136.1` | `R. 136 EPÜ` | HIGH |
| `b588fa64…` | (orphan) Wiedereinsetzungsantrag (DPMA) | 2 months | `DE.PatG.123.2` | `§ 123 PatG` | HIGH |
**Naming note on the two Wiedereinsetzung-`§ 123 PatG` rows.** Both `c24d494c…` ("§ 123 PatG" name) and `b588fa64…` ("DPMA" name) map to the same statute § 123 PatG (Wiedereinsetzung) applies to all DPMA-Verfahren, so the duplication is a pure naming choice. mig 097 fills both; potential dedup is a separate question 4 FLAG-A).
---
## 2. Proceeding-typed rows (53)
Grouped by `proceeding_types.code`. Within each group: alphabetical by `code`.
### 2.1 `upc.inf.cfi` — Verletzungsverfahren CFI (4 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `inf.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.118 but this is the court's own decision, not a party deadline | **FLAG-B** |
| `inf.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | RoP.101 ff. governs interim procedure; not a single norm | **FLAG-B** |
| `inf.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.111-117 (oral procedure); court-scheduled | **FLAG-B** |
| `inf.soc` | Klageerhebung (Statement of claim) | event | filing | `RoP.013.1` | `UPC.RoP.13.1` | RoP.13 Statement of claim contents | HIGH |
### 2.2 `upc.rev.cfi` — Nichtigkeitsverfahren CFI (6 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `rev.app` | Nichtigkeitsklage | event | filing | `RoP.042` | `UPC.RoP.42` | RoP.42 Statement for revocation | HIGH |
| `rev.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | court-issued, not a party deadline | **FLAG-B** |
| `rev.interim` | Zwischenverfahren | event | hearing | *(NULL)* | *(NULL)* | not a single norm | **FLAG-B** |
| `rev.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
| `rev.reply` | Replik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 Reply to defence in revocation | MED (**FLAG-C**: duration vs. norm) |
| `rev.rejoin` | Duplik | 2 months | filing | `RoP.052` | `UPC.RoP.52` | RoP.52 Rejoinder | MED (**FLAG-C**: duration vs. norm) |
**FLAG-C:** RoP.52(1) sets the reply to 2 months but RoP.52(2) sets the rejoinder to 1 month from service of the reply. m's `rev.rejoin` says 2 months verify whether the rule duration is correct or whether `RoP.52.2` (1 month) is the right citation. Cross-check with the existing `rev.rejoin_cci` row which uses RoP.056.4 (cci context); the main-pleadings rejoinder lives in RoP.52.
### 2.3 `upc.pi.cfi` — Einstweilige Maßnahmen (4 rules)
All four rules are currently NULL on both fields.
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `pi.app` | Antrag | event | filing | `RoP.206` | `UPC.RoP.206` | RoP.206 Application for provisional measures | HIGH |
| `pi.oral` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | RoP.209 at judge's discretion | **FLAG-B** |
| `pi.order` | Beschluss | event | decision | *(NULL)* | *(NULL)* | RoP.211 court-issued | **FLAG-B** |
| `pi.response` | Erwiderung | event | filing | *(NULL)* | *(NULL)* | RoP.209.1 judge sets time; no statutory period | **FLAG-B** (alt: `RoP.209.1` / `UPC.RoP.209.1` to flag as court-set) |
### 2.4 `upc.apl.merits` — Berufungsverfahren Merits (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `app.decision` | Entscheidung | event | decision | *(NULL)* | *(NULL)* | RoP.350 appellate decision | **FLAG-B** |
| `app.oral` | Mündliche Verhandlung | event | hearing | `RoP.243` | `UPC.RoP.243` | RoP.243 oral procedure in appeal | MED |
| `app.response` | Berufungserwiderung | 2 months | filing | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 Statement of response | MED (**FLAG-C**: RoP.235.1 says 3 months for main-judgment appeals; 2 months may be a residual from a different appeal track. Verify duration vs. norm.) |
### 2.5 `upc.apl.order` — Berufungsverfahren Anordnungen (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `app_ord.order` | Anordnung / angegriffene Entscheidung | event | decision | *(NULL)* | *(NULL)* | trigger event for orders-appeal; RoP.220.1.c references it | **FLAG-B** (alt: `RoP.220.1.c` to surface) |
### 2.6 `upc.apl.cost` — Berufungsverfahren Kosten (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `cost.decision` | Kostenfestsetzungsbeschluss | event | decision | *(NULL)* | *(NULL)* | RoP.150 ff. cost decision in the assessment proceedings | **FLAG-B** |
### 2.7 `upc.dmgs.cfi` — Schadensbemessungsverfahren (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `damages.app` | Antrag auf Schadensbemessung | event | filing | `RoP.131` | `UPC.RoP.131` | RoP.131 Application for damages determination | HIGH |
### 2.8 `upc.disc.cfi` — Bucheinsichtsverfahren (1 rule)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `disc.app` | Antrag auf Bucheinsicht | event | filing | `RoP.141` | `UPC.RoP.141` | RoP.141 Application for order to lay open books | HIGH |
### 2.9 `de.inf.lg` — Verletzungsverfahren LG (5 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_inf.klage` | Klageerhebung | event | filing | `§ 253 ZPO` | `DE.ZPO.253` *(already set)* | § 253 ZPO Klageschrift | HIGH (rule_code only) |
| `de_inf.replik` | Replik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | § 273 ZPO vorbereitende Anordnungen / court-set period (Düsseldorfer Praxis) | MED (**FLAG-D**: 4 weeks is local LG practice, no statutory period; flag `is_court_set=true` already true in DB) |
| `de_inf.duplik` | Duplik | 4 weeks | filing | `§ 273 ZPO` | `DE.ZPO.273` | same | MED (**FLAG-D**) |
| `de_inf.termin` | Haupttermin | event | hearing | *(NULL)* | *(NULL)* | § 272 / § 137 ZPO court-scheduled | **FLAG-B** |
| `de_inf.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 300 ZPO court-issued | **FLAG-B** |
### 2.10 `de.inf.olg` — Berufungsverfahren OLG Verletzung (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_inf_olg.urteil_lg` | Zustellung LG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO Zustellung von Urteilen | MED (**FLAG-E**: service-trigger event may be NULL per philosophy) |
| `de_inf_olg.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | court-scheduled | **FLAG-B** |
| `de_inf_olg.urteil_olg` | OLG-Urteil | event | decision | *(NULL)* | *(NULL)* | court-issued | **FLAG-B** |
### 2.11 `de.inf.bgh` — Revision/NZB BGH Verletzung (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_inf_bgh.urteil_olg` | Zustellung OLG-Urteil | event | filing (trigger) | `§ 317 ZPO` | `DE.ZPO.317` | § 317 ZPO Zustellung | MED (**FLAG-E**) |
| `de_inf_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 555 i.V.m. § 137 ZPO court-scheduled | **FLAG-B** |
| `de_inf_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 562, § 563 ZPO court-issued | **FLAG-B** |
### 2.12 `de.null.bpatg` — Nichtigkeitsverfahren BPatG (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_null.klage` | Nichtigkeitsklage | event | filing | `§ 81 PatG` | `DE.PatG.81.1` | § 81 PatG Nichtigkeitsklage einreichen | HIGH |
| `de_null.termin` | Mündliche Verhandlung | event | hearing | *(NULL)* | *(NULL)* | § 89 PatG | **FLAG-B** |
| `de_null.urteil` | Urteil | event | decision | *(NULL)* | *(NULL)* | § 84 PatG | **FLAG-B** |
### 2.13 `de.null.bgh` — Berufung BGH Nichtigkeit (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `de_null_bgh.urteil_bpatg` | Zustellung BPatG-Urteil | event | filing (trigger) | `§ 99 PatG` | `DE.PatG.99.1` | § 99 PatG verweist auf ZPO; Zustellung der BPatG-Urteile | MED (**FLAG-E**) |
| `de_null_bgh.termin` | Mündliche Verhandlung BGH | event | hearing | *(NULL)* | *(NULL)* | § 113 PatG i.V.m. ZPO | **FLAG-B** |
| `de_null_bgh.urteil_bgh` | BGH-Urteil | event | decision | *(NULL)* | *(NULL)* | § 119 PatG | **FLAG-B** |
### 2.14 `dpma.opp.dpma` — Einspruchsverfahren DPMA (2 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `dpma_opp.publish` | Veröffentlichung der Erteilung | event | filing (trigger) | `§ 58 PatG` | `DE.PatG.58.1` | § 58(1) PatG Veröffentlichung der Erteilung im Patentblatt | HIGH |
| `dpma_opp.entscheidung` | DPMA-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 47 PatG ff. | **FLAG-B** |
### 2.15 `dpma.appeal.bpatg` — Beschwerdeverfahren BPatG vs. DPMA (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `dpma_bpatg.entscheidung` | Zustellung DPMA-Entscheidung | event | filing (trigger) | `§ 47 PatG` | `DE.PatG.47.1` | § 47 PatG Zustellung der Entscheidung im DPMA-Verfahren | MED (**FLAG-E**: trigger-event citation. Alternative `§ 127 PatG` for service procedure.) |
| `dpma_bpatg.entsch_bpatg` | BPatG-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 79 PatG | **FLAG-B** |
| `dpma_bpatg.termin` | Mündliche Verhandlung BPatG | event | hearing | *(NULL)* | *(NULL)* | § 78 PatG | **FLAG-B** |
### 2.16 `dpma.appeal.bgh` — Rechtsbeschwerdeverfahren BGH (2 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `dpma_bgh.entsch_bpatg` | Zustellung BPatG-Entscheidung | event | filing (trigger) | `§ 79 PatG` | `DE.PatG.79.1` | § 79 PatG Zustellung der BPatG-Entscheidung | MED (**FLAG-E**) |
| `dpma_bgh.entsch_bgh` | BGH-Entscheidung | event | decision | *(NULL)* | *(NULL)* | § 107 PatG | **FLAG-B** |
### 2.17 `epa.grant.exa` — EP-Erteilungsverfahren (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `ep_grant.filing` | Anmeldung | event | filing | `Art. 75 EPÜ` | `EU.EPÜ.75` | Art. 75 EPÜ Filing of European patent application | HIGH |
| `ep_grant.search` | Recherchenbericht | 6 months | decision | `Art. 92 EPÜ` | `EU.EPÜ.92` | Art. 92 EPÜ Drawing up of the European search report | MED (the 6-month figure is a Richtwert per `deadline_notes` not a statutory deadline. Could also cite `R. 65 EPÜ` if we want the issuance procedure.) |
| `ep_grant.grant` | Erteilung (B1) | event | decision | `Art. 97 EPÜ` | `EU.EPÜ.97.1` | Art. 97(1) EPÜ Decision to grant | HIGH |
### 2.18 `epa.opp.opd` — Einspruchsverfahren EPA (2 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `epa_opp.grant` | Veröffentlichung der Erteilung | event | filing (trigger) | `Art. 97 EPÜ` | `EU.EPÜ.97.3` | Art. 97(3) EPÜ mention of grant; trigger for the 9-month Einspruchsfrist (Art. 99(1) EPÜ) | HIGH |
| `epa_opp.entsch` | Entscheidung | event | decision | `Art. 101 EPÜ` | `EU.EPÜ.101` | Art. 101 EPÜ Decision on opposition | HIGH |
### 2.19 `epa.opp.boa` — Beschwerdeverfahren BoA (3 rules)
| code | name | duration | event_type | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|---|
| `epa_app.entsch` | Zustellung der Beschwerdeentscheidung | event | filing (trigger) | `R. 111 EPÜ` | `EU.EPC-R.111` | R. 111 EPÜ Form and notification of decisions | MED (**FLAG-E**: service-trigger citation. Could also cite `Art. 119 EPÜ` for notification.) |
| `epa_app.oral` | Mündliche Verhandlung | event | hearing | `Art. 116 EPÜ` | `EU.EPÜ.116` | Art. 116 EPÜ Oral proceedings | HIGH |
| `epa_app.entsch2` | Entscheidung | event | decision | `Art. 111 EPÜ` | `EU.EPÜ.111` | Art. 111 EPÜ Decision in respect of appeals | HIGH |
---
## 3. Orphan rows — `proceeding_type_id IS NULL` and `code IS NULL` (77)
Identified by `id` (UUID first 8 chars) + name. These are the older Fristenrechner catalogue rows that pre-date the proceeding-typed slice and were never re-anchored to a proceeding. Many are 1:1 duplicates of rules that now live in proceeding-typed form.
### 3.1 UPC RoP — main-pleadings track (15)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `e34097d6…` | Klageerwiderung | 3 mo | `RoP.023` | `UPC.RoP.23.1` | RoP.23.1 Statement of defence | HIGH | dup of `inf.sod` |
| `7d8a4804…` | Nichtigkeitswiderklage | 3 mo | `RoP.025.1` | `UPC.RoP.25.1` | RoP.25.1 Counterclaim for revocation | HIGH | |
| `c7523e6b…` | Verletzungswiderklage | 2 mo | `RoP.049.2.b` | `UPC.RoP.49.2.b` | RoP.49.2.b Counterclaim for infringement in revocation | HIGH | dup of `rev.cc_inf` |
| `c57f62f8…` | Vorgängige Einrede | 1 mo | `RoP.019.1` | `UPC.RoP.19.1` | RoP.19.1 Preliminary objection | HIGH | dup of `inf.prelim` / `rev.prelim` |
| `cec1a865…` | Erwiderung Nichtigkeitswiderklage **+** Replik Klageerwiderung | 2 mo | `RoP.029.a` | `UPC.RoP.29.a` | RoP.29.a / .b combined Defence-to-CCR + Reply to SoD | HIGH (**FLAG-F**: combined-pleading orphan m to confirm one citation is sufficient or whether row should be split) |
| `84b390e0…` | Replik auf die Klageerwiderung | 2 mo | `RoP.029.b` | `UPC.RoP.29.b` | RoP.29.b Reply to defence | HIGH | dup of `inf.reply` |
| `176cc1ca…` | Duplik zur Replik auf die Klageerwiderung | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | RoP.29.c Rejoinder | HIGH | dup of `inf.rejoin` |
| `02ae9c1f…` | Duplik zur Replik, Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.029.c` | `UPC.RoP.29.c` | combined: RoP.29.c + RoP.32.3 | MED (**FLAG-F**) |
| `ec2a1274…` | Replik auf Erwiderung Widerklage, Duplik Replik Klageerwiderung, Erwiderung Patentänderungsantrag | 2 mo | `RoP.029.d` | `UPC.RoP.29.d` | combined: RoP.29.d + RoP.29.c + RoP.32.1 | MED (**FLAG-F**: three-norm combined row) |
| `a32dcec1…` | Erwiderung auf die Nichtigkeitsklage | 2 mo | `RoP.049.1` | `UPC.RoP.49.1` | RoP.49.1 Defence to revocation | HIGH | dup of `rev.defence` |
| `37bd034b…` | Replik Erwiderung Nichtigkeitsklage + Erwiderung Patentänderungsantrag + Erwiderung Verletzungswiderklage | 2 mo | `RoP.051` | `UPC.RoP.51` | combined: RoP.51 + RoP.49.2.a-reply + RoP.56.1 | MED (**FLAG-F**) |
| `1b5c6dee…` | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 mo | `RoP.052` | `UPC.RoP.52` | RoP.52 Rejoinder in revocation | MED |
| `bea86f9b…` | Erwiderung auf die Verletzungswiderklage | 2 mo | `RoP.056.1` | `UPC.RoP.56.1` | RoP.56.1 | HIGH | dup of `rev.def_cci` |
| `4834c957…` | Replik auf die Erwiderung zur Verletzungswiderklage | 1 mo | `RoP.056.3` | `UPC.RoP.56.3` | RoP.56.3 | HIGH | dup of `rev.reply_def_cci` |
| `7b548c48…` | Duplik (Verletzungswiderklage + Patentänderungsantrag) | 1 mo | `RoP.056.4` | `UPC.RoP.56.4` | combined: RoP.56.4 + RoP.32.3 | MED (**FLAG-F**) |
### 3.2 UPC RoP — Patentänderungs-Track (5)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `fb7050c6…` | Antrag auf Patentänderung | 2 mo | `RoP.030.1` | `UPC.RoP.30.1` | RoP.30.1 (infringement context) | MED (**FLAG-G**: 2 rows with identical name + 2-month dur; one likely refers to `RoP.30.1` infringement, other to `RoP.49.2.a` revocation) |
| `21e67ac1…` | Antrag auf Patentänderung | 2 mo | `RoP.049.2.a` | `UPC.RoP.49.2.a` | RoP.49.2.a (revocation context) | MED (**FLAG-G**) |
| `7e65a434…` | Erwiderung auf den Antrag auf Patentänderung | 2 mo | `RoP.032.1` | `UPC.RoP.32.1` | RoP.32.1 Defence to application to amend | HIGH | dup of `inf.def_to_amend` |
| `dfd52792…` | Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 Reply | HIGH | dup of `inf.reply_def_amd` |
| `8cdf54eb…` | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 mo | `RoP.032.3` | `UPC.RoP.32.3` | RoP.32.3 Rejoinder | HIGH | dup of `inf.rejoin_amd` |
### 3.3 UPC RoP — appeal track (16)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `1dfba5b1…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | RoP.224.1.a Notice of appeal, main-judgment track | HIGH | dup of `app.notice` |
| `5c0508f4…` | Berufungsschrift gegen Entscheidung nach R. 220.1(a)/(b) | 2 mo | `RoP.224.1.a` | `UPC.RoP.224.1.a` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
| `d560b3b6…` | Berufungsschrift gegen Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | RoP.224.1.b Notice of appeal, orders/leave track | HIGH | dup of `app_ord.with_leave`-family |
| `791fd0f7…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | RoP.225.1 Statement of grounds, main track | HIGH | dup of `app.grounds` |
| `573df3d1…` | Berufungsbegründung Entscheidung R. 220.1(a)/(b) | 4 mo | `RoP.225.1` | `UPC.RoP.225.1` | same | HIGH | duplicate-of-duplicate (**FLAG-A**) |
| `c3a369f9…` | Berufungsbegründung Anordnung R. 220.1(c) / R. 220.2 / 221.3 | 15 d | `RoP.225.2` | `UPC.RoP.225.2` | RoP.225.2 Statement of grounds, orders/leave | MED (**FLAG-H**: RoP.225.2 form; verify 15d figure aligns with current RoP version) |
| `91e367dd…` | Berufung (Anordnungen & mit Zulassung) | 15 d | `RoP.224.1.b` | `UPC.RoP.224.1.b` | same | MED | dup of `app_ord.with_leave` |
| `ccb916df…` | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d | `RoP.221.1` | `UPC.RoP.221.1` | RoP.221.1 Leave to appeal cost decisions | HIGH | dup of `cost.leave_app` |
| `342e749d…` | Antrag auf Ermessensüberprüfung | 15 d | `RoP.220.3` | `UPC.RoP.220.3` | RoP.220.3 Discretionary review | HIGH | dup of `app_ord.discretion` |
| `d4f739cd…` | Anfechtung einer Entscheidung über Verwerfung der Berufung als unzulässig | 1 mo | `RoP.234.1` | `UPC.RoP.234.1` | RoP.234 Inadmissibility of appeal review | MED (**FLAG-H**: confirm sub-paragraph; RoP.234 governs the topic but the 1-month review window may sit elsewhere) |
| `10374392…` | Berufungserwiderung (zur Berufung nach R. 224.2(a)) | 3 mo | `RoP.235.1` | `UPC.RoP.235.1` | RoP.235.1 Statement of response, main track | HIGH |
| `4c585c6d…` | Berufungserwiderung (zur Berufung nach R. 224.2(b)) | 15 d | `RoP.235.4` | `UPC.RoP.235.4` | RoP.235.4 Statement of response, orders/leave track | MED (**FLAG-H**: confirm RoP.235.4 vs. RoP.235.2 in current RoP version) |
| `6e39b653…` | Anschlussberufungsschrift (zur Berufung R. 224.2(a)) | 3 mo | `RoP.237.1` | `UPC.RoP.237.1` | RoP.237.1 Cross-appeal | HIGH |
| `a00e51bb…` | Anschlussberufungsschrift (zur Berufung R. 224.2(b)) | 15 d | `RoP.237.2` | `UPC.RoP.237.2` | RoP.237 Cross-appeal in orders track | MED (**FLAG-H**) |
| `6b989e85…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(a)) | 2 mo | `RoP.238.1` | `UPC.RoP.238.1` | RoP.238.1 Reply to cross-appeal | HIGH | dup of `app.cross_a_reply` |
| `e78f4652…` | Erwiderung auf Anschlussberufungsschrift (R. 224.2(b)) | 15 d | `RoP.238.2` | `UPC.RoP.238.2` | RoP.238.2 Reply to cross-appeal, orders track | HIGH | dup of `app_ord.cross_reply` |
### 3.4 UPC RoP — Schadensbemessung / Rechnungslegung (7)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf | dedup hint |
|---|---|---|---|---|---|---|---|
| `d414f603…` | Erwiderung Antrag auf Schadensersatzbemessung | 2 mo | `RoP.137.2` | `UPC.RoP.137.2` | RoP.137.2 | HIGH | dup of `damages.defence` |
| `9f39e263…` | Replik Erwiderung Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.reply` |
| `067ffdf0…` | Duplik Replik Schadensersatzbemessung | 1 mo | `RoP.139` | `UPC.RoP.139` | RoP.139 | HIGH | dup of `damages.rejoin` |
| `429b8ec0…` | Erwiderung Antrag auf Rechnungslegung | 2 mo | `RoP.142.2` | `UPC.RoP.142.2` | RoP.142.2 Defence in account procedure | HIGH | dup of `disc.defence` |
| `8d36fc76…` | Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.reply` |
| `ed82fec9…` | Duplik Replik Erwiderung Rechnungslegung | 14 d | `RoP.142.3` | `UPC.RoP.142.3` | RoP.142.3 | HIGH | dup of `disc.rejoin` |
| `eed69e8b…` | Antrag auf Kostenentscheidung | 1 mo | `RoP.151` | `UPC.RoP.151` | RoP.151 Application for cost decision | HIGH | dup of `inf.cost_app` |
### 3.5 UPC RoP — provisional / PI (6)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `ba335c99…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | RoP.213.1 31 days or 20 working days after PI granted | HIGH |
| `d886f46f…` | Beginn des Hauptsacheverfahrens | 31 d | `RoP.213.1` | `UPC.RoP.213.1` | same duplicate row (**FLAG-A**) | HIGH |
| `1f1f72ef…` | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d | `RoP.197.3` | `UPC.RoP.197.3` | RoP.197.3 Review of evidence preservation order | HIGH |
| `3e2f5697…` | Erneuerung der Schutzschrift | 6 mo | `RoP.207.9` | `UPC.RoP.207.9` | RoP.207.9 Protective letter, 6-month validity | HIGH |
### 3.6 UPC RoP — feststellungs / Widerruf-Track (4)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `521bf607…` | Erwiderung auf negative Feststellungsklage | 2 mo | *(NULL)* | *(NULL)* | UPC declaration of non-infringement procedure follows RoP.49 ff. by analogy (RoP.69 references) | **FLAG-I**: negative declaration track has no single statutory norm; cite either `RoP.069` / `UPC.RoP.69` (general procedure) or leave NULL pending m's call |
| `e887b1fb…` | Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
| `0cf1d755…` | Duplik Replik Erwiderung negative Feststellungsklage | 1 mo | *(NULL)* | *(NULL)* | same | **FLAG-I** |
### 3.7 UPC RoP — formalities / Registry (14)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `d058f412…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | RoP.16.4 Notice to remedy defects | HIGH |
| `c690c323…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | same duplicate (**FLAG-A**) | HIGH |
| `5f2884a4…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `13600049…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `ceb780ba…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `d51c50eb…` | Mängelbeseitigung / Zahlung | 14 d | `RoP.016.4` | `UPC.RoP.16.4` | duplicate (**FLAG-A**) | HIGH |
| `3bc40027…` | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d | `RoP.016.5` | `UPC.RoP.16.5` | RoP.16.5 Written observations after Registry notice | MED |
| `69e356b7…` | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d | `RoP.262.2` | `UPC.RoP.262.2` | RoP.262.2 Confidentiality vis-à-vis public (note in DB confirms) | HIGH |
| `57e6eeca…` | Berichtigung von Entscheidungen und Anordnungen | 1 mo | `RoP.353` | `UPC.RoP.353` | RoP.353 Rectification of decisions/orders | HIGH |
| `8ec233b9…` | Antrag auf Überprüfung verfahrensleitender Anordnung | 15 d | `RoP.333.1` | `UPC.RoP.333.1` | RoP.333.1 Review of procedural order | HIGH |
| `d124c95b…` | Antrag auf Aufhebung oder Änderung Entscheidung des Amtes | 1 mo | *(NULL)* | *(NULL)* | unclear which Amts-Entscheidung this targets Registry order? Unitary-effect refusal? | **FLAG-J** (recommend NULL; ask m what proceeding-context this row maps to) |
| `0531b6ba…` | Antrag auf Aufhebung Entscheidung EPA über einheitliche Wirkung | 3 wk | `RoP.097.1` | `UPC.RoP.97.1` | RoP.97.1 Action against EPO decision on unitary effect | MED (**FLAG-H**: verify 3-week period vs. norm; current RoP gives 1 month for such applications under R.88 EPÜ-UPC; possibly outdated) |
| `6b6b967c…` | Antrag auf Verweisung an die Zentralkammer | 10 d | `RoP.037.4` | `UPC.RoP.37.4` | RoP.37 governs division apportionment; .4 is the 10-day observation period | MED (**FLAG-H**: confirm sub-paragraph) |
| `002c2ba7…` | Antrag auf Folgemaßnahmen rechtskräftiger Validitätsentscheidung | 2 mo | *(NULL)* | *(NULL)* | likely refers to post-revocation register-correction request; norm uncertain | **FLAG-J** |
### 3.8 UPC RoP — translation / interpretation (3)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `bb7bafcb…` | Antrag auf Simultanübersetzung | 1 mo (before) | `RoP.109.1` | `UPC.RoP.109.1` | RoP.109.1 Request for simultaneous interpretation | HIGH |
| `8c682cff…` | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 wk (before) | `RoP.109.5` | `UPC.RoP.109.5` | RoP.109.5 Notice of own-cost interpreter | MED (**FLAG-H**: confirm sub-paragraph; RoP.109 governs interpretation but the specific 2-week notice rule may sit at .4 or .5) |
| `9ed513c1…` | Einreichung von Übersetzungen von Schriftstücken | 1 mo | `RoP.007.2` | `UPC.RoP.7.2` | RoP.7.2 Language of documents | MED (**FLAG-H**: alternative `RoP.7.4` for translations of party-submitted documents) |
| `902cc5d5…` | Klärung von Übersetzungsfragen | 2 wk | *(NULL)* | *(NULL)* | unclear which "Übersetzungsfrage" rule | **FLAG-J** |
### 3.9 UPC RoP — review / rehearing (2)
| id8 | name (orphan) | dur | proposed `rule_code` | proposed `legal_source` | source-of-truth | conf |
|---|---|---|---|---|---|---|
| `372e86e3…` | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.2 Application for rehearing within 2 months | HIGH |
| `58de9573…` | Antrag auf Wiederaufnahme (Straftat) | 2 mo | `RoP.247.2` | `UPC.RoP.247.2` | RoP.247.1(b) substantively (criminal act ground); RoP.247.2 for the 2-month period | HIGH |
### 3.10 Already-cited orphans (covered in § 1 Easy wins, 7 rows)
`20254f4e…`, `3c36f149…`, `f1099cf6…`, `c24d494c…`, `d40d9be7…`, `23c6f445…`, `b588fa64…` see § 1.
---
## 4. FLAG summary — items needing m's call
| FLAG | Topic | Count | Decision needed |
|---|---|---|---|
| **A** | Genuine duplicate orphan rows (same name + dur + citation) | ~10 | Confirm the dedup pass should happen in mig 097 (or a follow-up). Recommended: leave duplicates in place for mig 097 (fills all of them with the same citation); dedup separately so the rule-resolution semantics don't drift. |
| **B** | Court-scheduled / court-issued event rows (Mündliche Verhandlung, Urteil, Entscheidung) | ~22 | Confirm NULL is the right default. Alternative: cite the framing norm with a "context" note. |
| **C** | UPC RoP duration vs. norm mismatch (`rev.reply` / `rev.rejoin` / `app.response`) | 3 | Verify the rule durations are correct as stored proposed citations are canonical but rule duration may be from an older RoP version. |
| **D** | German LG patent practice: 4-week replik/duplik (court-set) | 2 | Confirm `§ 273 ZPO` is the cite m wants (no statutory period, framing norm only). |
| **E** | Service / trigger-event citations (`§ 317 ZPO`, `R. 111 EPÜ` etc.) | 6 | These are anchor-events for downstream timers, not deadlines. Confirm whether to cite (current proposal) or leave NULL. |
| **F** | Combined-pleading orphan rows (one row = several norms) | 5 | Confirm one citation is acceptable, or whether the rows should be split before mig 097 (out of scope here). |
| **G** | Twin "Antrag auf Patentänderung" orphans (2-mo, identical name) | 2 | Confirm one is infringement-context (`RoP.30.1`), the other revocation-context (`RoP.49.2.a`). |
| **H** | RoP sub-paragraph uncertainty (current text vs. older version) | ~8 | Spot-check against current published RoP; my citations are canonical but small `.x` numbers may need a tweak. |
| **I** | Negative-declaration track (no single UPC norm) | 3 | Confirm citing `RoP.69` (procedure-by-analogy) vs. leaving NULL. |
| **J** | Orphan with unclear scope | 3 | `d124c95b…` (Aufhebung Entscheidung des Amtes), `002c2ba7…` (Folgemaßnahmen Validitätsentscheidung), `902cc5d5…` (Klärung Übersetzungsfragen). m to identify which UPC norm. |
---
## 5. Side-fix (recommend bundled in mig 097)
**RoP-display normalization**: `rev.defence` currently carries `rule_code = "RoP.49.1"`. All other RoP rules under 100 use 3-digit padding (`RoP.029.a`, `RoP.049.2.a` etc.). mig 097 should normalize `RoP.49.1 → RoP.049.1` in that one row, while filling the 130 NULL rows with consistently padded values.
```sql
-- side-fix candidate
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.049.1'
WHERE rule_code = 'RoP.49.1'
AND code = 'rev.defence'; -- only one row; idempotent
```
This is opt-in; m to confirm before mig 097 ships.
---
## 6. Migration 097 hints (for the coder who writes it)
**Shape m has asked for:**
- `UPDATE paliad.deadline_rules SET rule_code = …, legal_source = … WHERE id = … AND rule_code IS NULL AND legal_source IS [NULL|expected];`
- Idempotent: `WHERE rule_code IS NULL` (or `IS DISTINCT FROM`) guard so re-applying is a no-op.
- Backup snapshot: `CREATE TABLE paliad.deadline_rules_pre_097 AS SELECT * FROM paliad.deadline_rules` before any UPDATEs.
- Wrap in `audit_reason = 't-paliad-208 legal-citation backfill'` (matches `paliad.audit_log` pattern used elsewhere).
- Touch only the m-approved rows from § 1, § 2, § 3 FLAG rows (those with `*(NULL)*` in the proposed columns) stay untouched until m resolves them.
- Side-fix § 5 (`RoP.49.1 → RoP.049.1`) only if m confirms.
**Counts the migration should match (assuming m approves all HIGH proposals as-is):**
- Easy wins 1): 8 `rule_code` UPDATEs (legal_source already set)
- Proceeding-typed HIGH/MED proposals 2): ~25 rows
- Orphan HIGH/MED proposals 3): ~50 rows
- Total expected `rule_code` writes: ~83 rows
- Total expected `legal_source` writes: ~75 rows (8 of the easy wins already have one)
- FLAG rows left NULL: ~47 rows pending m's decisions
---
## 7. Open questions for m
1. **NULL for event-markers (FLAG-B):** confirm NULL is correct for the 22 court-scheduled / court-issued event rows. If m wants citations there too, I'll do a second pass.
2. **Trigger-event citations (FLAG-E):** apply `§ 317 ZPO` to LG/OLG service rows, or leave NULL?
3. **Duplicates (FLAG-A):** mig 097 fills duplicates with the same citation; do you want a separate dedup pass scheduled (filing `t-paliad-21x`) or is the duplicate count acceptable for now?
4. **Combined-pleading orphans (FLAG-F):** keep one citation per row, or split each row into N rows before mig 097?
5. **Negative-declaration track (FLAG-I):** cite `RoP.69` by analogy, or leave NULL?
6. **Side-fix (§ 5):** normalize the one `RoP.49.1` outlier as part of mig 097?
Once m answers, head can re-task this same worker (or a fresh coder) to write mig 097 against the approved proposals.

View File

@@ -0,0 +1,52 @@
# t-paliad-207 follow-up scope — close-out assessment
**Author:** fermi (inventor)
**Date:** 2026-05-20
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
---
## 0. What shipped under t-paliad-207
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
4. **mig 100**`upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
7. **Notes toggle**`Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
Filed two follow-up issues during the session:
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
## 1. Why (A) DONE
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
## 2. Optional tail — would file as discrete issues, not a fermi slice
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
| # | Candidate | Size | Already covered? |
|---|---|---|---|
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
| 3 | **Touch-device fallback for the ⓘ hover hint**`title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
## 3. Recommendation
Close t-paliad-207. Fire fermi. The remaining tail (items 16 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.

View File

@@ -71,16 +71,16 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-code" data-i18n="admin.rules.edit.field.code">Code</label>
<input type="text" id="f-code" className="admin-rules-input" />
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rule-Code (zit.)</label>
<label htmlFor="f-rule-code" data-i18n="admin.rules.edit.field.rule_code">Rechtsgrundlage (Kurzform)</label>
<input type="text" id="f-rule-code" className="admin-rules-input" placeholder="z. B. RoP.151" />
</div>
<div className="form-field">
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage</label>
<input type="text" id="f-legal-source" className="admin-rules-input" />
<label htmlFor="f-legal-source" data-i18n="admin.rules.edit.field.legal_source">Rechtsgrundlage (Langform)</label>
<input type="text" id="f-legal-source" className="admin-rules-input" placeholder="z. B. UPC.RoP.151" />
</div>
</div>
</fieldset>

View File

@@ -93,7 +93,7 @@ export function renderAdminRulesList(): string {
type="text"
id="rules-filter-search"
className="admin-rules-input"
placeholder="Name, Code, rule_code..."
placeholder="Name, Submission Code, Rechtsgrundlage..."
data-i18n-placeholder="admin.rules.filter.search.placeholder"
autocomplete="off"
/>
@@ -104,7 +104,8 @@ export function renderAdminRulesList(): string {
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.rules.col.code">Code</th>
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
<th data-i18n="admin.rules.col.priority">Priorit&auml;t</th>
@@ -113,7 +114,7 @@ export function renderAdminRulesList(): string {
</tr>
</thead>
<tbody id="rules-tbody">
<tr><td colspan={6} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
<tr><td colspan={7} className="admin-rules-loading" data-i18n="admin.rules.loading">Lade...</td></tr>
</tbody>
</table>
</div>

View File

@@ -11,7 +11,10 @@ interface Rule {
id: string;
proceeding_type_id?: number | null;
parent_id?: string | null;
code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
@@ -255,7 +258,7 @@ function populateForm() {
setInput("f-name", rule.name);
setInput("f-name-en", rule.name_en);
setInput("f-description", rule.description ?? "");
setInput("f-code", rule.code ?? "");
setInput("f-submission-code", rule.submission_code ?? "");
setInput("f-rule-code", rule.rule_code ?? "");
setInput("f-legal-source", rule.legal_source ?? "");
setInput("f-proceeding", rule.proceeding_type_id ?? "");

View File

@@ -11,7 +11,10 @@ import { initSidebar } from "./sidebar";
interface Rule {
id: string;
proceeding_type_id?: number | null;
code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (the legal citation, e.g. `RoP.013.1`).
submission_code?: string | null;
rule_code?: string | null;
name: string;
name_en: string;
@@ -219,7 +222,8 @@ function renderRulesTable() {
const name = (r: Rule) => (getLang() === "en" ? r.name_en : r.name) || r.name;
tbody.innerHTML = rules.map((r) => `
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
<td class="admin-rules-col-code"><code>${esc(r.rule_code || r.code || "")}</code></td>
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
<td>${esc(name(r))}</td>
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>

View File

@@ -728,6 +728,13 @@ function wireRowHandlers(tbody: HTMLElement) {
if (cb && !cb.disabled) {
cb.addEventListener("change", async () => {
if (!cb.checked) return;
const titleCell = row.querySelector<HTMLElement>(".events-title");
const title = (titleCell?.textContent || "").trim();
const msg = t("deadlines.complete.confirm").replace("{title}", title || "?");
if (!window.confirm(msg)) {
cb.checked = false;
return;
}
cb.disabled = true;
try {
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });

View File

@@ -57,6 +57,19 @@ type ProcedureView = "timeline" | "columns";
// HLC team than the single vertical line.
let procedureView: ProcedureView = "columns";
// Notes toggle — off by default; per-rule notes render as a compact
// ⓘ hover icon. Flipped on, they expand under each card. Choice is
// localStorage-persisted (paliad.fristen.notes-show key shared with
// /tools/verfahrensablauf so the preference carries across both).
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
function readNotesPref(): boolean {
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
}
function writeNotesPref(on: boolean): void {
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showNotes = readNotesPref();
onLangChange(() => {
if (lastResponse) renderProcedureResults(lastResponse);
// Update trigger event name if a proceeding is selected
@@ -108,25 +121,28 @@ async function calculate() {
const triggerDate = dateInput.value;
if (!triggerDate || !selectedType) return;
// Priority date — only meaningful for EP_GRANT (Art. 93 EPÜ publish-anchor).
// Priority date — only meaningful for epa.grant.exa (Art. 93 EPÜ publish-anchor).
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
const priorityDate = selectedType === "EP_GRANT" && priorityInput?.value ? priorityInput.value : "";
const priorityDate = selectedType === "epa.grant.exa" && priorityInput?.value ? priorityInput.value : "";
// Flags — three proceeding-specific checkboxes:
// UPC_INF: with_ccr (always available); with_amend (nested under
// with_ccr — R.30 application is only available with a CCR).
// UPC_REV: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two
// independent gates; both can be on simultaneously.
// Flags — proceeding-specific checkboxes:
// upc.inf.cfi: with_ccr (always available); with_amend (nested under
// with_ccr — R.30 application is only available with a CCR).
// upc.rev.cfi: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two
// independent gates; both can be on simultaneously.
// R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18 call): it's
// an always-available optional submission, surfaced as priority='optional'
// without a separate checkbox.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
const flags: string[] = [];
if (selectedType === "UPC_INF") {
if (selectedType === "upc.inf.cfi") {
if (ccrFlag?.checked) flags.push("with_ccr");
if (ccrFlag?.checked && infAmendFlag?.checked) flags.push("with_amend");
}
if (selectedType === "UPC_REV") {
if (selectedType === "upc.rev.cfi") {
if (revAmendFlag?.checked) flags.push("with_amend");
if (revCciFlag?.checked) flags.push("with_cci");
}
@@ -388,8 +404,8 @@ function renderProcedureResults(data: DeadlineResponse) {
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true })
: renderTimelineBody(data, { showParty: true, editable: true });
? renderColumnsBody(data, { editable: true, showNotes })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
printBtn.style.display = "block";
@@ -504,22 +520,22 @@ function selectProceeding(btn: HTMLButtonElement) {
document.getElementById("trigger-event")!.textContent = name;
// Conditional inputs:
// priority-date → EP_GRANT
// ccr-flag → UPC_INF only
// inf-amend-flag → UPC_INF only, but disabled until ccr-flag is on
// priority-date → epa.grant.exa
// ccr-flag → upc.inf.cfi only
// inf-amend-flag → upc.inf.cfi only, but disabled until ccr-flag is on
// (R.30 amend only available with a CCR)
// rev-amend-flag → UPC_REV only
// rev-cci-flag → UPC_REV only
// rev-amend-flag → upc.rev.cfi only
// rev-cci-flag → upc.rev.cfi only
const priorityRow = document.getElementById("priority-date-row");
if (priorityRow) priorityRow.style.display = selectedType === "EP_GRANT" ? "" : "none";
if (priorityRow) priorityRow.style.display = selectedType === "epa.grant.exa" ? "" : "none";
const ccrRow = document.getElementById("ccr-flag-row");
if (ccrRow) ccrRow.style.display = selectedType === "UPC_INF" ? "" : "none";
if (ccrRow) ccrRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
const infAmendRow = document.getElementById("inf-amend-flag-row");
if (infAmendRow) infAmendRow.style.display = selectedType === "UPC_INF" ? "" : "none";
if (infAmendRow) infAmendRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
const revAmendRow = document.getElementById("rev-amend-flag-row");
if (revAmendRow) revAmendRow.style.display = selectedType === "UPC_REV" ? "" : "none";
if (revAmendRow) revAmendRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
const revCciRow = document.getElementById("rev-cci-flag-row");
if (revCciRow) revCciRow.style.display = selectedType === "UPC_REV" ? "" : "none";
if (revCciRow) revCciRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
syncInfAmendEnabled();
populateCourtPickerCore("court-picker-row", "court-picker", selectedType);
@@ -658,6 +674,18 @@ document.addEventListener("DOMContentLoaded", () => {
const saveBtn = document.getElementById("fristen-save-cta");
if (saveBtn) saveBtn.addEventListener("click", openSaveModal);
// Notes toggle — restores last preference on load + re-renders when
// the user flips it. Lives in the same toggle bar as the view picker.
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
if (notesShowCb) {
notesShowCb.checked = showNotes;
notesShowCb.addEventListener("change", () => {
showNotes = notesShowCb.checked;
writeNotesPref(showNotes);
if (lastResponse) renderProcedureResults(lastResponse);
});
}
// View toggle (timeline vs. columns layout) for procedure mode.
initViewToggle();
@@ -2607,25 +2635,31 @@ function inboxOptionLabel(value: string): string {
// Slice 2: cascade-segment ↔ fristenrechner-code bridge. The event_categories
// taxonomy uses kebab-case segments under the `cms-eingang.*` buckets to
// represent proceedings (`upc-inf`, `de-bgh-null`, …); paliad.projects
// stores the fristenrechner code in UPPER_SNAKE form (`UPC_INF`, …).
// Most pairs follow a direct kebab↔snake mapping; a few — particularly
// the DE BGH variants and the DPMA BGH Rechtsbeschwerde — were given
// different segment orderings and need an explicit override. Any code
// not in the map degrades to "no proceeding-axis narrowing" — better
// silent than wrong (design §11.6).
// binds to fristenrechner codes by id and the lookup yields the
// lowercase dot-separated taxonomy ratified by mig 096
// (`upc.inf.cfi`, `de.inf.bgh`, …). The event_categories slugs are NOT
// renamed by mig 096 — they live in a separate taxonomy and the kebab
// form is presentation-layer (it appears in URL fragments). This map
// is the bridge. Any code not in the map degrades to "no proceeding-
// axis narrowing" — better silent than wrong (design §11.6).
//
// upc.ccr.cfi is the illustrative peer added by mig 096; it shares the
// `upc-inf` kebab segment because rules live on upc.inf.cfi with
// with_ccr=true (design doc S1, proceeding_mapping.go).
const fristenrechnerCodeToCascadeSegment: Record<string, string> = {
UPC_INF: "upc-inf",
UPC_REV: "upc-rev",
UPC_APP: "upc-app",
UPC_PI: "upc-pi",
DE_INF: "de-inf",
DE_NULL: "de-null",
DE_INF_BGH: "de-bgh-inf",
DE_NULL_BGH: "de-bgh-null",
DPMA_OPP: "dpma-opp",
DPMA_BGH_RB: "dpma-bgh",
EPA_OPP: "epa-opp",
EPA_APP: "epa-app",
"upc.inf.cfi": "upc-inf",
"upc.ccr.cfi": "upc-inf",
"upc.rev.cfi": "upc-rev",
"upc.apl.merits": "upc-app",
"upc.pi.cfi": "upc-pi",
"de.inf.lg": "de-inf",
"de.null.bpatg": "de-null",
"de.inf.bgh": "de-bgh-inf",
"de.null.bgh": "de-bgh-null",
"dpma.opp.dpma": "dpma-opp",
"dpma.appeal.bgh":"dpma-bgh",
"epa.opp.opd": "epa-opp",
"epa.opp.boa": "epa-app",
};
// Set of kebab segments known to be proceeding-axis values. Used to
@@ -2931,7 +2965,7 @@ function rowHtml(row: RowSpec, rowNumber: number): string {
${prefilledTag}
</span>
<button type="button" class="fristen-row-edit" data-row-edit="${escAttr(row.rowId)}">
<span data-i18n="deadlines.row.edit">&auml;ndern</span>
<span>${escHtml(t("deadlines.row.edit"))}</span>
</button>
</div>
</div>`;

View File

@@ -211,9 +211,9 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.de": "Deutsche Gerichte",
"deadlines.epa": "EPA",
"deadlines.dpma": "DPMA",
"deadlines.dpma_opp": "Einspruch DPMA",
"deadlines.dpma_bpatg_beschwerde": "Beschwerde BPatG (DPMA)",
"deadlines.dpma_bgh_rb": "Rechtsbeschwerde BGH",
"deadlines.dpma.opp.dpma": "Einspruch DPMA",
"deadlines.dpma.appeal.bpatg": "Beschwerde BPatG (DPMA)",
"deadlines.dpma.appeal.bgh": "Rechtsbeschwerde BGH",
"deadlines.trigger.event": "Ausl\u00f6sendes Ereignis:",
"deadlines.trigger.date": "Datum:",
"deadlines.trigger.label": "Ausgangsdatum",
@@ -226,22 +226,25 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.calculate": "Fristen berechnen",
"deadlines.print": "Drucken",
"deadlines.reset": "\u2190 Neu berechnen",
"deadlines.upc_inf": "Verletzungsverfahren",
"deadlines.upc_rev": "Nichtigkeitsklage",
"deadlines.upc_pi": "Einstw. Ma\u00dfnahmen",
"deadlines.upc_app": "Berufung",
"deadlines.upc_damages": "Schadensbemessung",
"deadlines.upc_discovery": "Bucheinsicht",
"deadlines.upc_cost_appeal": "Berufung Kosten",
"deadlines.upc_app_orders": "Berufung Anordnungen",
"deadlines.de_inf": "Verletzungsklage (LG)",
"deadlines.de_inf_olg": "Berufung OLG",
"deadlines.de_inf_bgh": "Revision/NZB BGH",
"deadlines.de_null": "Nichtigkeitsverfahren",
"deadlines.de_null_bgh": "Berufung BGH (Nichtigk.)",
"deadlines.epa_opp": "Einspruchsverfahren",
"deadlines.epa_app": "Beschwerdeverfahren",
"deadlines.ep_grant": "EP-Erteilungsverfahren",
"deadlines.upc.inf.cfi": "Verletzungsverfahren",
"deadlines.upc.rev.cfi": "Nichtigkeitsklage",
"deadlines.upc.ccr.cfi": "Widerklage auf Nichtigkeit",
"deadlines.upc.pi.cfi": "Einstw. Ma\u00dfnahmen",
"deadlines.upc.apl.merits": "Berufung",
"deadlines.upc.dmgs.cfi": "Schadensbemessung",
"deadlines.upc.disc.cfi": "Bucheinsicht",
"deadlines.upc.apl.cost": "Berufung Kosten",
"deadlines.upc.apl.order": "Berufung Anordnungen",
"deadlines.de.group.inf": "Verletzungsverfahren",
"deadlines.de.group.null": "Nichtigkeitsverfahren",
"deadlines.de.inf.lg": "LG (1. Instanz)",
"deadlines.de.inf.olg": "OLG (Berufung)",
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
"deadlines.de.null.bpatg": "BPatG (1. Instanz)",
"deadlines.de.null.bgh": "BGH (Berufung)",
"deadlines.epa.opp.opd": "Einspruchsverfahren",
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
"deadlines.party.claimant": "Kl\u00e4ger",
"deadlines.party.defendant": "Beklagter",
"deadlines.party.court": "Gericht",
@@ -297,6 +300,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.label": "Ansicht:",
"deadlines.view.timeline": "Zeitstrahl",
"deadlines.view.columns": "Spalten",
"deadlines.notes.show": "Hinweise anzeigen",
"deadlines.col.proactive": "Proaktiv",
"deadlines.col.court": "Gericht",
"deadlines.col.reactive": "Reaktiv",
@@ -724,6 +728,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.urgency.soon": "In K\u00fcrze",
"deadlines.urgency.later": "Sp\u00e4ter",
"deadlines.complete.action": "Erledigen",
"deadlines.complete.confirm": "Frist \u201e{title}\u201c wirklich als erledigt markieren?",
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
"aggregation.toggle.subtree": "Inkl. Unterprojekte",
@@ -835,6 +840,18 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.month.9": "Oktober",
"cal.month.10": "November",
"cal.month.11": "Dezember",
"cal.view.month": "Monat",
"cal.view.week": "Woche",
"cal.view.day": "Tag",
"cal.month.prev": "Vorheriger Monat",
"cal.month.next": "Nächster Monat",
"cal.week.prev": "Vorherige Woche",
"cal.week.next": "Nächste Woche",
"cal.day.prev": "Vorheriger Tag",
"cal.day.next": "Nächster Tag",
"cal.day.back_to_month": "Zurück zum Monat",
"cal.day.open_day": "Tagesansicht öffnen",
"cal.day.no_entries": "Keine Einträge an diesem Tag.",
// Akten detail — Fristen tab (Phase E)
@@ -1149,9 +1166,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.title.placeholder": "z.B. Siemens AG | Siemens v. Huawei | EP 1 234 567",
"projects.field.reference": "Interne Referenz (optional)",
"projects.field.reference.placeholder": `z.B. ${FIRM}-2026-0042`,
"projects.field.client_number": "Client-Nr. (7 Ziffern)",
"projects.field.matter_number": "Matter-Nr. (7 Ziffern)",
"projects.field.clientmatter.hint": `${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt (\u00fcberschreibbar).`,
"projects.field.client_number": "Client-Nr. (6 Ziffern)",
"projects.field.matter_number": "Matter-Nr. (6 Ziffern)",
"projects.field.clientmatter.hint": `${FIRM}-Billing-Nummern. Format CCCCCC.MMMMMM. Client-Nr. wird an Unterprojekte vererbt (\u00fcberschreibbar).`,
"projects.field.billing_reference": "Billing-Referenz (optional)",
"projects.field.netdocuments_url": "netDocuments-URL (optional)",
"projects.field.industry": "Branche",
@@ -2201,6 +2218,9 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
"approvals.error.awaiting_approval": "Diese Anforderung wartet auf Genehmigung.",
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
"approvals.disabled.self_approval": "Du kannst eigene Anträge nicht genehmigen",
"approvals.disabled.not_authorized": "Du hast keine Genehmigungsberechtigung für diesen Antrag",
"approvals.disabled.revoke_not_requester": "Nur der Antragsteller kann zurückziehen",
"approvals.pending.badge": "Wartet auf Genehmigung",
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
@@ -2232,6 +2252,12 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.calendar": "Kalender",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.",
"views.timeline.zoom.label": "Zoom",
"views.timeline.zoom.in": "Heranzoomen",
"views.timeline.zoom.out": "Herauszoomen",
"views.timeline.zoom.1y": "±1 J.",
"views.timeline.zoom.2y": "±2 J.",
"views.timeline.zoom.all": "Alles",
"views.save_as": "Als Ansicht speichern",
"views.action.edit": "Bearbeiten",
"views.empty.title": "Keine Einträge gefunden.",
@@ -2415,9 +2441,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Alle",
"admin.rules.filter.search": "Suche",
"admin.rules.filter.search.placeholder": "Name, Code, rule_code…",
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
"admin.rules.col.code": "Code",
"admin.rules.col.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.col.legal_citation": "Rechtsgrundlage",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Verfahrenstyp",
"admin.rules.col.priority": "Priorität",
@@ -2480,9 +2507,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Beschreibung",
"admin.rules.edit.field.code": "Code",
"admin.rules.edit.field.rule_code": "Rule-Code (zit.)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage",
"admin.rules.edit.field.submission_code": "Submission Code / Einreichung-Kennung",
"admin.rules.edit.field.rule_code": "Rechtsgrundlage (Kurzform)",
"admin.rules.edit.field.legal_source": "Rechtsgrundlage (Langform)",
"admin.rules.edit.field.proceeding": "Verfahrenstyp",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger-Ereignis",
@@ -2772,9 +2799,9 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.de": "German Courts",
"deadlines.epa": "EPO",
"deadlines.dpma": "DPMA",
"deadlines.dpma_opp": "Opposition DPMA",
"deadlines.dpma_bpatg_beschwerde": "Appeal BPatG (DPMA)",
"deadlines.dpma_bgh_rb": "Legal Appeal BGH",
"deadlines.dpma.opp.dpma": "Opposition DPMA",
"deadlines.dpma.appeal.bpatg": "Appeal BPatG (DPMA)",
"deadlines.dpma.appeal.bgh": "Legal Appeal BGH",
"deadlines.trigger.event": "Trigger event:",
"deadlines.trigger.date": "Date:",
"deadlines.trigger.label": "Trigger date",
@@ -2787,22 +2814,25 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.calculate": "Calculate Deadlines",
"deadlines.print": "Print",
"deadlines.reset": "\u2190 Start Over",
"deadlines.upc_inf": "Infringement",
"deadlines.upc_rev": "Revocation",
"deadlines.upc_pi": "Provisional Measures",
"deadlines.upc_app": "Appeal",
"deadlines.upc_damages": "Damages Determination",
"deadlines.upc_discovery": "Lay-open Books",
"deadlines.upc_cost_appeal": "Cost-Decision Appeal",
"deadlines.upc_app_orders": "Order Appeal (15-day)",
"deadlines.de_inf": "Infringement (Regional Court)",
"deadlines.de_inf_olg": "Appeal OLG",
"deadlines.de_inf_bgh": "Revision / NZB BGH",
"deadlines.de_null": "Nullity",
"deadlines.de_null_bgh": "Appeal BGH (Nullity)",
"deadlines.epa_opp": "Opposition",
"deadlines.epa_app": "Appeal",
"deadlines.ep_grant": "Grant Procedure",
"deadlines.upc.inf.cfi": "Infringement",
"deadlines.upc.rev.cfi": "Revocation",
"deadlines.upc.ccr.cfi": "Counterclaim for Revocation",
"deadlines.upc.pi.cfi": "Provisional Measures",
"deadlines.upc.apl.merits": "Appeal",
"deadlines.upc.dmgs.cfi": "Damages Determination",
"deadlines.upc.disc.cfi": "Lay-open Books",
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
"deadlines.upc.apl.order": "Order Appeal (15-day)",
"deadlines.de.group.inf": "Infringement proceedings",
"deadlines.de.group.null": "Nullity proceedings",
"deadlines.de.inf.lg": "LG (1st instance)",
"deadlines.de.inf.olg": "OLG (Appeal)",
"deadlines.de.inf.bgh": "BGH (Revision / NZB)",
"deadlines.de.null.bpatg": "BPatG (1st instance)",
"deadlines.de.null.bgh": "BGH (Appeal)",
"deadlines.epa.opp.opd": "Opposition",
"deadlines.epa.opp.boa": "Appeal",
"deadlines.epa.grant.exa": "Grant Procedure",
"deadlines.party.claimant": "Claimant",
"deadlines.party.defendant": "Defendant",
"deadlines.party.court": "Court",
@@ -2858,6 +2888,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.label": "View:",
"deadlines.view.timeline": "Timeline",
"deadlines.view.columns": "Columns",
"deadlines.notes.show": "Show details",
"deadlines.col.proactive": "Proactive",
"deadlines.col.court": "Court",
"deadlines.col.reactive": "Reactive",
@@ -3285,6 +3316,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.urgency.soon": "Soon",
"deadlines.urgency.later": "Later",
"deadlines.complete.action": "Complete",
"deadlines.complete.confirm": "Mark deadline \u201c{title}\u201d as completed?",
// t-paliad-139 \u2014 subtree aggregation toggle and attribution chip
"aggregation.toggle.subtree": "Incl. sub-projects",
@@ -3396,6 +3428,18 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.month.9": "October",
"cal.month.10": "November",
"cal.month.11": "December",
"cal.view.month": "Month",
"cal.view.week": "Week",
"cal.view.day": "Day",
"cal.month.prev": "Previous month",
"cal.month.next": "Next month",
"cal.week.prev": "Previous week",
"cal.week.next": "Next week",
"cal.day.prev": "Previous day",
"cal.day.next": "Next day",
"cal.day.back_to_month": "Back to month",
"cal.day.open_day": "Open day view",
"cal.day.no_entries": "Nothing scheduled this day.",
// Akten detail — Fristen tab (Phase E)
@@ -3698,9 +3742,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.title.placeholder": "e.g. Siemens AG | Siemens v. Huawei | EP 1 234 567",
"projects.field.reference": "Internal reference (optional)",
"projects.field.reference.placeholder": `e.g. ${FIRM}-2026-0042`,
"projects.field.client_number": "Client no. (7 digits)",
"projects.field.matter_number": "Matter no. (7 digits)",
"projects.field.clientmatter.hint": `${FIRM} billing numbers. Format CCCCCCC.MMMMMMM. Client no. is inherited by sub-projects (overridable).`,
"projects.field.client_number": "Client no. (6 digits)",
"projects.field.matter_number": "Matter no. (6 digits)",
"projects.field.clientmatter.hint": `${FIRM} billing numbers. Format CCCCCC.MMMMMM. Client no. is inherited by sub-projects (overridable).`,
"projects.field.billing_reference": "Billing reference (optional)",
"projects.field.netdocuments_url": "netDocuments URL (optional)",
"projects.field.industry": "Industry",
@@ -4746,6 +4790,9 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
"approvals.error.awaiting_approval": "This entity is awaiting approval.",
"approvals.error.request_not_pending": "This request is no longer open.",
"approvals.disabled.self_approval": "You cannot approve your own requests",
"approvals.disabled.not_authorized": "You are not authorized to approve this request",
"approvals.disabled.revoke_not_requester": "Only the requester can withdraw",
"approvals.pending.badge": "Awaiting approval",
"approvals.withdraw.cta": "Withdraw approval request",
"approvals.withdraw.confirm": "Withdraw the approval request?",
@@ -4777,6 +4824,12 @@ const translations: Record<Lang, Record<string, string>> = {
"views.shape.calendar": "Calendar",
"views.shape.timeline": "Timeline",
"views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.",
"views.timeline.zoom.label": "Zoom",
"views.timeline.zoom.in": "Zoom in",
"views.timeline.zoom.out": "Zoom out",
"views.timeline.zoom.1y": "±1 yr",
"views.timeline.zoom.2y": "±2 yr",
"views.timeline.zoom.all": "All",
"views.save_as": "Save as view",
"views.action.edit": "Edit",
"views.empty.title": "No matches found.",
@@ -4959,9 +5012,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.filter.lifecycle": "Lifecycle",
"admin.rules.filter.lifecycle.any": "Any",
"admin.rules.filter.search": "Search",
"admin.rules.filter.search.placeholder": "Name, code, rule_code…",
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
"admin.rules.col.code": "Code",
"admin.rules.col.submission_code": "Submission code",
"admin.rules.col.legal_citation": "Legal citation",
"admin.rules.col.name": "Name",
"admin.rules.col.proceeding": "Proceeding type",
"admin.rules.col.priority": "Priority",
@@ -5024,9 +5078,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.rules.edit.field.name": "Name (DE)",
"admin.rules.edit.field.name_en": "Name (EN)",
"admin.rules.edit.field.description": "Description",
"admin.rules.edit.field.code": "Code",
"admin.rules.edit.field.rule_code": "Rule code (cit.)",
"admin.rules.edit.field.legal_source": "Legal source",
"admin.rules.edit.field.submission_code": "Submission code",
"admin.rules.edit.field.rule_code": "Legal citation (short form)",
"admin.rules.edit.field.legal_source": "Legal citation (long form)",
"admin.rules.edit.field.proceeding": "Proceeding type",
"admin.rules.edit.field.proceeding.none": "—",
"admin.rules.edit.field.trigger": "Trigger event",

View File

@@ -1472,7 +1472,7 @@ function initCounterclaimRoute(
msg.className = "form-msg";
}
// Populate proceeding-type select on first open. Only UPC types
// make sense for a CCR (Nichtigkeit/CCI); pre-select UPC_REV.
// make sense for a CCR (Nichtigkeit/CCI); pre-select upc.rev.cfi.
if (procedureSel && procedureSel.options.length === 0) {
const types = await loadProceedingTypes();
const upcTypes = types.filter((t) => (t.jurisdiction ?? "").toUpperCase() === "UPC");
@@ -1481,7 +1481,7 @@ function initCounterclaimRoute(
const opt = document.createElement("option");
opt.value = String(ty.id);
opt.textContent = `${ty.code}${langEN ? ty.name_en || ty.name : ty.name}`;
if (ty.code === "UPC_REV") opt.selected = true;
if (ty.code === "upc.rev.cfi") opt.selected = true;
procedureSel.appendChild(opt);
}
}

View File

@@ -25,6 +25,48 @@ let lastResponse: DeadlineResponse | null = null;
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
// Notes toggle — when off (default), per-rule descriptive notes render
// as a compact ⓘ icon next to the meta line (hover for full text). When
// on, the full notes block expands under each card. Choice persists in
// localStorage so a reload or recalc keeps the user's preference.
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
function readNotesPref(): boolean {
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
}
function writeNotesPref(on: boolean): void {
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showNotes = readNotesPref();
// Jurisdiction display prefix for the proceeding-summary chip + the
// trigger-event placeholder. Same forum slugs the .proceeding-group
// `data-forum` attribute carries in verfahrensablauf.tsx /
// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the
// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE
// Verletzungsklage etc.) once the picker collapses.
const FORUM_LABEL: Record<string, string> = {
upc: "UPC",
de: "DE",
epa: "EPA",
dpma: "DPMA",
};
function jurisdictionFor(btn: HTMLButtonElement): string {
const group = btn.closest<HTMLElement>(".proceeding-group");
const forum = group?.dataset.forum || "";
return FORUM_LABEL[forum] || "";
}
function proceedingDisplayName(btn: HTMLButtonElement): string {
const name = btn.querySelector("strong")?.textContent || "";
const jur = jurisdictionFor(btn);
return jur ? `${jur} ${name}` : name;
}
function activeProceedingButton(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
}
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
// so rapid input changes never let a stale response overwrite a fresh
// one.
@@ -46,6 +88,31 @@ function showStep(n: number) {
}
}
// Read the proceeding-specific flag checkboxes and assemble the
// payload the calculator expects. Mirrors fristenrechner.ts so the
// gating semantics stay identical: with_amend on upc.inf.cfi is
// nested under with_ccr (R.30 is only available with a CCR);
// upc.rev.cfi exposes with_amend + with_cci as two independent
// gates. R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18
// call): it's just an always-available optional submission, so it
// has no checkbox.
function readFlags(): string[] {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const revAmend = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
const revCci = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
const flags: string[] = [];
if (selectedType === "upc.inf.cfi") {
if (ccr?.checked) flags.push("with_ccr");
if (ccr?.checked && infAmend?.checked) flags.push("with_amend");
}
if (selectedType === "upc.rev.cfi") {
if (revAmend?.checked) flags.push("with_amend");
if (revCci?.checked) flags.push("with_cci");
}
return flags;
}
async function doCalc() {
const seq = ++calcSeq;
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
@@ -61,6 +128,7 @@ async function doCalc() {
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
flags: readFlags(),
courtId,
});
if (seq !== calcSeq) return;
@@ -70,25 +138,56 @@ async function doCalc() {
showStep(3);
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. The root rule (isRootEvent=true) is
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank).
function triggerEventLabelFor(data: DeadlineResponse): string {
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
return data.proceedingName || "";
}
function syncTriggerEventLabel() {
const triggerEventEl = document.getElementById("trigger-event");
if (!triggerEventEl) return;
if (lastResponse) {
triggerEventEl.textContent = triggerEventLabelFor(lastResponse);
} else {
triggerEventEl.textContent = "—";
}
}
function renderResults(data: DeadlineResponse) {
const container = document.getElementById("timeline-container");
if (!container) return;
const printBtn = document.getElementById("fristen-print-btn");
const toggle = document.getElementById("fristen-view-toggle");
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
// Header shows the picked proceeding with its jurisdiction prefix
// so the user can tell UPC Verletzungsverfahren apart from DE
// Verletzungsklage once the picker collapses.
const activeBtn = activeProceedingButton();
const procName = activeBtn ? proceedingDisplayName(activeBtn)
: tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
const headerHtml = `<div class="timeline-header">
<strong>${procName}</strong>
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data)
: renderTimelineBody(data);
? renderColumnsBody(data, { showNotes })
: renderTimelineBody(data, { showParty: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";
syncTriggerEventLabel();
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
@@ -100,18 +199,47 @@ function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string)
if (summaryName && displayName) summaryName.textContent = displayName;
}
// syncFlagRows shows/hides the proceeding-specific checkbox rows
// based on selectedType. Same disposition as fristenrechner.ts —
// the with_amend nested-under-ccr semantic is enforced via
// syncInfAmendEnabled().
function syncFlagRows() {
const show = (id: string, when: boolean) => {
const el = document.getElementById(id);
if (el) el.style.display = when ? "" : "none";
};
show("ccr-flag-row", selectedType === "upc.inf.cfi");
show("inf-amend-flag-row", selectedType === "upc.inf.cfi");
show("rev-amend-flag-row", selectedType === "upc.rev.cfi");
show("rev-cci-flag-row", selectedType === "upc.rev.cfi");
syncInfAmendEnabled();
}
// R.30 amendment-application is only available with a CCR — disable
// (and clear) the nested inf-amend checkbox while ccr is off so the
// calc payload stays coherent. Mirrors fristenrechner.ts.
function syncInfAmendEnabled() {
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (!ccr || !infAmend) return;
infAmend.disabled = !ccr.checked;
if (!ccr.checked) infAmend.checked = false;
}
function selectProceeding(btn: HTMLButtonElement) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
selectedType = btn.dataset.code || "";
const name = btn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
// Trigger-event label fires from the calc response (root rule).
// Until step 3 renders, fall back to an em-dash placeholder.
lastResponse = null;
syncTriggerEventLabel();
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
setProceedingPickerCollapsed(true, name);
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
showStep(2);
scheduleCalc(0);
@@ -169,18 +297,47 @@ document.addEventListener("DOMContentLoaded", () => {
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
// Flag-checkbox listeners — each flip triggers a fresh calc so the
// timeline re-projects with the new gating. ccr-flag additionally
// enables/disables the nested inf-amend row.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
if (ccrFlag) ccrFlag.addEventListener("change", () => {
syncInfAmendEnabled();
scheduleCalc(0);
});
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
const cb = document.getElementById(id) as HTMLInputElement | null;
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
});
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
// Notes toggle — restores last preference on load + re-renders when
// the user flips it. Lives in the same toggle bar as the view picker.
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
if (notesShowCb) {
notesShowCb.checked = showNotes;
notesShowCb.addEventListener("change", () => {
showNotes = notesShowCb.checked;
writeNotesPref(showNotes);
if (lastResponse) renderResults(lastResponse);
});
}
initViewToggle();
onLangChange(() => {
if (lastResponse) renderResults(lastResponse);
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
// Active-button name updates with language change (the data-i18n
// pass swaps the inner <strong>'s text). Re-collapse the summary
// chip and re-derive the trigger event label from the lang-current
// calc response.
const activeBtn = activeProceedingButton();
if (activeBtn) {
const name = activeBtn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
const summary = document.getElementById("proceeding-summary-name");
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
}
if (lastResponse) renderResults(lastResponse);
syncTriggerEventLabel();
});
// Pre-select the first proceeding tile so users see a timeline

View File

@@ -1,16 +1,25 @@
import { initI18n, t, type I18nKey } from "./i18n";
import { initSidebar } from "./sidebar";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } from "./views/types";
import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape, DataSource } from "./views/types";
import { renderListShape } from "./views/shape-list";
import { renderCardsShape } from "./views/shape-cards";
import { renderCalendarShape } from "./views/shape-calendar";
import { renderTimelineShape } from "./views/shape-timeline-cv";
import type { ChartHandle } from "./views/shape-timeline-chart";
import { mountFilterBar, type BarHandle, type AxisKey } from "./filter-bar";
// /views and /views/{slug} client. Loads the saved or system view, runs
// it via /api/views/{slug}/run, and dispatches to the matching render-
// shape component. Shape-switcher chips toggle the live render without
// re-fetching (the rows are already in memory).
//
// t-paliad-211 — the per-view filter bar (`mountFilterBar`) lives between
// the shape chips and the render hosts. The saved view's filter_spec is
// the baseline; the bar overlays the user's per-session tweaks and POSTs
// `/api/views/{slug}/run` with the effective spec as override (the
// substrate accepts `{filter: ...}` per views.go:283). Axes are picked
// from the spec's data sources so a deadline-only view doesn't expose
// the appointment-type chip cluster and vice versa.
initI18n();
initSidebar();
@@ -30,6 +39,8 @@ interface ViewMeta {
let currentMeta: ViewMeta | null = null;
let currentRows: ViewRunResult | null = null;
let currentRender: RenderSpec | null = null;
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
bindShapeChips();
@@ -54,9 +65,10 @@ async function hydrate(): Promise<void> {
return;
}
currentMeta = meta;
currentRender = meta.render;
document.title = `${meta.name} — Paliad`;
updateHeader(meta);
await runAndRender(meta);
mountBar(meta);
if (meta.user_view_id) {
fireAndForget(`/api/user-views/${meta.user_view_id}/touch`, "POST");
}
@@ -97,57 +109,97 @@ async function resolveMeta(slug: string): Promise<ViewMeta | null> {
return null;
}
async function runAndRender(meta: ViewMeta): Promise<void> {
// mountBar wires the filter-bar to the view's saved spec. The bar runs
// the spec through `/api/views/{slug}/run` whenever the user tweaks an
// axis, and the onResult callback re-paints into the active shape host.
function mountBar(meta: ViewMeta): void {
const host = document.getElementById("views-filter-bar");
const toolbar = document.getElementById("views-toolbar");
const loading = document.getElementById("views-loading");
if (loading) loading.hidden = false;
if (toolbar) toolbar.hidden = false;
if (host) host.hidden = false;
if (!host) return;
// Tear down any prior bar (re-mount on lang change isn't supported
// here, but a future Phase-2 axis switch may need this).
if (bar) {
bar.destroy();
bar = null;
}
const axes = axesForSources(meta.filter.sources);
// surfaceKey scoped per-view-slug so two views remember their own
// density/sort prefs independently.
const surfaceKey = `views.${meta.slug}`;
bar = mountFilterBar(host, {
baseFilter: meta.filter,
baseRender: meta.render,
axes,
surfaceKey,
systemViewSlug: meta.slug,
// The saved view IS the baseline; "Speichern als Sicht" remains
// available for users who want to fork.
showSaveAsView: !meta.is_system,
userViewId: meta.user_view_id,
onResult: (result, effective) => {
if (loading) loading.hidden = true;
currentRows = result;
currentRender = effective.render;
paintRows(result, effective.render);
},
});
}
// axesForSources picks the filter-bar axes a saved view's data sources
// support. Universal axes (time / personal_only / sort) always render;
// per-source predicates only render when the view's spec actually
// queries that source — otherwise the chip would be a no-op.
function axesForSources(sources: DataSource[]): AxisKey[] {
const set = new Set(sources);
const out: AxisKey[] = ["time"];
if (set.has("deadline")) out.push("deadline_status");
if (set.has("appointment")) out.push("appointment_type");
if (set.has("approval_request")) {
out.push("approval_viewer_role");
out.push("approval_status");
out.push("approval_entity_type");
}
if (set.has("project_event")) out.push("project_event_kind");
out.push("personal_only");
out.push("sort");
return out;
}
function paintRows(result: ViewRunResult, render: RenderSpec): void {
const empty = document.getElementById("views-empty");
const errorEl = document.getElementById("views-error");
const toolbar = document.getElementById("views-toolbar");
if (loading) loading.hidden = false;
if (empty) empty.hidden = true;
if (errorEl) errorEl.hidden = true;
if (toolbar) toolbar.hidden = false;
let result: ViewRunResult;
try {
const r = await fetch(`/api/views/${encodeURIComponent(meta.slug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) {
showError(`${r.status}: ${r.statusText}`);
return;
}
result = (await r.json()) as ViewRunResult;
} catch (e) {
showError(t("views.error.network"));
return;
}
if (loading) loading.hidden = true;
currentRows = result;
if (result.inaccessible_project_ids && result.inaccessible_project_ids.length > 0) {
showInaccessibleToast(result.inaccessible_project_ids.length);
}
if (result.rows.length === 0) {
setActiveShape(null);
if (empty) {
empty.hidden = false;
const hint = document.getElementById("views-empty-hint");
if (hint) hint.textContent = filterSummary(meta.filter);
if (hint && currentMeta) hint.textContent = filterSummary(currentMeta.filter);
}
return;
}
if (empty) empty.hidden = true;
setActiveShape(meta.render.shape);
renderShape(meta.render.shape, meta.render, result.rows);
setActiveShape(render.shape);
renderShape(render.shape, render, result.rows);
}
function setActiveShape(shape: RenderShape): void {
function setActiveShape(shape: RenderShape | null): void {
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
const el = document.getElementById(host);
if (el) el.hidden = !host.endsWith("-" + shape);
if (el) el.hidden = shape === null ? true : !host.endsWith("-" + shape);
}
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.shape === shape);
@@ -223,9 +275,10 @@ function bindShapeChips(): void {
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
btn.addEventListener("click", () => {
const shape = (btn.dataset.shape ?? "list") as RenderShape;
if (!currentMeta || !currentRows) return;
if (!currentRows || !currentRender) return;
// Override the shape transiently — doesn't mutate the saved spec.
const overrideRender = { ...currentMeta.render, shape };
const overrideRender = { ...currentRender, shape };
currentRender = overrideRender;
setActiveShape(shape);
renderShape(shape, overrideRender, currentRows.rows);
});

View File

@@ -1,14 +1,21 @@
import { t, type I18nKey, getLang } from "../i18n";
import { t, tDyn, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
// shape-calendar: month grid. Toggleable to week-view via per-shape
// config. Mirrors the look of /events?view=calendar but generic across
// sources.
// shape-calendar: month / week / day views. The view switcher is rendered
// inline above the grid; the active view persists in the URL via
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
// shareable deep-link. Each view buckets the same flat ViewRow[] by
// ISO-date — only the rendering differs.
type CalView = "month" | "week" | "day";
const VIEW_PARAM = "cal_view";
const DATE_PARAM = "cal_date";
const MAX_PILLS_PER_MONTH_CELL = 3;
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
host.innerHTML = "";
const cfg = render.calendar ?? {};
const view = cfg.default_view ?? "month";
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
// screens). Documented in design §9 trade-off 8.
@@ -19,15 +26,121 @@ export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render:
host.appendChild(notice);
}
const initialView = readView(cfg.default_view);
const anchor = readAnchor(rows);
paint(host, rows, anchor, initialView);
}
// paint redraws the calendar in the supplied view + anchor. Called from
// the view switcher and from the day/week navigation buttons. Each paint
// clears the host so we don't leak prior DOM.
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
// Keep the mobile-notice (first child) if present; everything else is
// re-rendered each time.
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
host.innerHTML = "";
if (notice) host.appendChild(notice);
const wrap = document.createElement("div");
wrap.className = `views-calendar views-calendar--${view}`;
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
writeURL(nextView, nextAnchor);
paint(host, rows, nextAnchor, nextView);
}));
if (view === "month") {
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
writeURL("day", clickedDate);
paint(host, rows, clickedDate, "day");
}));
} else if (view === "week") {
wrap.appendChild(renderWeek(anchor, rows));
} else {
wrap.appendChild(renderDay(anchor, rows));
}
const monthRef = pickMonthAnchor(rows);
wrap.appendChild(renderMonth(monthRef, rows));
host.appendChild(wrap);
}
function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
// --- Toolbar -------------------------------------------------------------
function renderToolbar(
view: CalView,
anchor: Date,
onNav: (view: CalView, anchor: Date) => void,
): HTMLElement {
const bar = document.createElement("div");
bar.className = "views-calendar-toolbar";
// View switcher: month / week / day chips.
const switcher = document.createElement("div");
switcher.className = "views-calendar-view-switcher agenda-chip-row";
switcher.setAttribute("role", "tablist");
for (const v of ["month", "week", "day"] as CalView[]) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
chip.dataset.calView = v;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", v === view ? "true" : "false");
chip.textContent = t(`cal.view.${v}` as I18nKey);
chip.addEventListener("click", () => {
if (v === view) return;
onNav(v, anchor);
});
switcher.appendChild(chip);
}
bar.appendChild(switcher);
// Prev / current-label / next. Step size depends on the view.
const nav = document.createElement("div");
nav.className = "views-calendar-nav";
const prev = document.createElement("button");
prev.type = "button";
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
prev.textContent = "";
prev.addEventListener("click", () => onNav(view, shift(anchor, view, -1)));
nav.appendChild(prev);
const label = document.createElement("span");
label.className = "views-calendar-nav-label";
label.textContent = formatRangeLabel(view, anchor);
nav.appendChild(label);
const next = document.createElement("button");
next.type = "button";
next.className = "btn-secondary btn-small views-calendar-nav-btn";
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
next.textContent = "";
next.addEventListener("click", () => onNav(view, shift(anchor, view, 1)));
nav.appendChild(next);
// Day/week view: provide a "Zurück zum Monat" link so users can climb
// back without hunting for the switcher chip.
if (view !== "month") {
const backToMonth = document.createElement("button");
backToMonth.type = "button";
backToMonth.className = "btn-link views-calendar-back-to-month";
backToMonth.textContent = t("cal.day.back_to_month");
backToMonth.addEventListener("click", () => onNav("month", anchor));
nav.appendChild(backToMonth);
}
bar.appendChild(nav);
return bar;
}
function navLabelKey(view: CalView, dir: "prev" | "next"): I18nKey {
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
}
// --- Month view ----------------------------------------------------------
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-month";
@@ -37,20 +150,22 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
wrap.appendChild(header);
// Weekday headers (Mon-Sun, ISO week).
const weekdayBar = document.createElement("div");
weekdayBar.className = "views-calendar-weekdays";
const weekdayKeys: I18nKey[] = ["cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu", "cal.day.fri", "cal.day.sat", "cal.day.sun"];
// Single grid with one column-template that the weekday row and the day
// cells share. The header row is added with `grid-column: span 7` so
// it spans the full width above the day grid (laid out below).
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const weekdayKeys: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
for (const k of weekdayKeys) {
const cell = document.createElement("div");
cell.className = "views-calendar-weekday";
cell.textContent = t(k);
weekdayBar.appendChild(cell);
grid.appendChild(cell);
}
wrap.appendChild(weekdayBar);
const grid = document.createElement("div");
grid.className = "views-calendar-grid";
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
@@ -63,47 +178,16 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
grid.appendChild(cell);
}
// Bucket rows by ISO date (yyyy-mm-dd).
const byDate = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (isNaN(d.getTime())) continue;
if (d.getMonth() !== anchor.getMonth() || d.getFullYear() !== anchor.getFullYear()) continue;
const key = isoDate(d);
const arr = byDate.get(key);
if (arr) arr.push(row);
else byDate.set(key, [row]);
}
// Bucket rows by ISO date (yyyy-mm-dd) within the visible month.
const byDate = bucketByDate(rows, (d) =>
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
);
for (let day = 1; day <= daysInMonth; day++) {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
const dayLabel = document.createElement("div");
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(day);
cell.appendChild(dayLabel);
const dateKey = isoDate(new Date(anchor.getFullYear(), anchor.getMonth(), day));
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
const dateKey = isoDate(dayDate);
const dayRows = byDate.get(dateKey) ?? [];
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, 3);
for (const row of visible) {
const li = document.createElement("li");
li.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
li.textContent = row.title;
li.title = row.title + (row.project_title ? `${row.project_title}` : "");
ul.appendChild(li);
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
more.className = "views-calendar-pill views-calendar-pill--more";
more.textContent = `+${dayRows.length - visible.length}`;
ul.appendChild(more);
}
cell.appendChild(ul);
}
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
grid.appendChild(cell);
}
@@ -111,14 +195,269 @@ function renderMonth(anchor: Date, rows: ViewRow[]): HTMLElement {
return wrap;
}
function pickMonthAnchor(rows: ViewRow[]): Date {
// Anchor on the first row's month, or "this month" if empty.
function renderMonthCell(
dayDate: Date,
dayNum: number,
dayRows: ViewRow[],
onDayDrill: (d: Date) => void,
): HTMLElement {
const cell = document.createElement("div");
cell.className = "views-calendar-cell";
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
// Day-number is a click-target that switches to the day view. We render
// it as a button to keep keyboard semantics; the surrounding cell stays
// a div so it doesn't compete with the inner row anchors.
const dayLabel = document.createElement("button");
dayLabel.type = "button";
dayLabel.className = "views-calendar-cell-day";
dayLabel.textContent = String(dayNum);
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
dayLabel.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
cell.appendChild(dayLabel);
if (dayRows.length > 0) {
const ul = document.createElement("ul");
ul.className = "views-calendar-pills";
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
for (const row of visible) {
ul.appendChild(renderPill(row));
}
if (dayRows.length > visible.length) {
const more = document.createElement("li");
const moreBtn = document.createElement("button");
moreBtn.type = "button";
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
moreBtn.textContent = `+${dayRows.length - visible.length}`;
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
moreBtn.addEventListener("click", (e) => {
e.stopPropagation();
onDayDrill(dayDate);
});
more.appendChild(moreBtn);
ul.appendChild(more);
}
cell.appendChild(ul);
}
return cell;
}
// --- Week view -----------------------------------------------------------
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-week";
const weekStart = startOfWeek(anchor);
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
wrap.appendChild(header);
const grid = document.createElement("div");
grid.className = "views-calendar-week-grid";
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
const col = renderWeekColumn(day, rows);
grid.appendChild(col);
}
wrap.appendChild(grid);
return wrap;
}
function renderWeekColumn(day: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const col = document.createElement("div");
col.className = "views-calendar-week-column";
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
const head = document.createElement("div");
head.className = "views-calendar-week-head";
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
const dow = document.createElement("span");
dow.className = "views-calendar-week-dow";
dow.textContent = t(weekdayKey);
const dnum = document.createElement("span");
dnum.className = "views-calendar-week-dnum";
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
head.appendChild(dow);
head.appendChild(dnum);
col.appendChild(head);
// No 3-row cap on week / day views — show everything for that day.
const dayRows = filterByDay(rows, day);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-week-empty";
empty.textContent = t("cal.day.no_entries");
col.appendChild(empty);
return col;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-week-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "week"));
ul.appendChild(li);
}
col.appendChild(ul);
return col;
}
// --- Day view ------------------------------------------------------------
function renderDay(anchor: Date, rows: ViewRow[]): HTMLElement {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
const wrap = document.createElement("div");
wrap.className = "views-calendar-day-wrap";
const header = document.createElement("h2");
header.className = "views-calendar-month-label";
header.textContent = anchor.toLocaleDateString(lang, {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
wrap.appendChild(header);
const dayRows = filterByDay(rows, anchor);
if (dayRows.length === 0) {
const empty = document.createElement("p");
empty.className = "views-calendar-day-empty";
empty.textContent = t("cal.day.no_entries");
wrap.appendChild(empty);
return wrap;
}
const ul = document.createElement("ul");
ul.className = "views-calendar-day-list";
for (const row of dayRows) {
const li = document.createElement("li");
li.appendChild(renderRowAnchor(row, "day"));
ul.appendChild(li);
}
wrap.appendChild(ul);
return wrap;
}
// --- Row rendering -------------------------------------------------------
function renderPill(row: ViewRow): HTMLElement {
const li = document.createElement("li");
const a = document.createElement("a");
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
a.href = rowHref(row);
a.textContent = row.title;
a.title = row.title + (row.project_title ? `${row.project_title}` : "");
// Pills are anchors — month-cell day-button click ignores them via
// stopPropagation on the button; cell-level handlers would intercept
// them otherwise.
a.addEventListener("click", (e) => e.stopPropagation());
li.appendChild(a);
return li;
}
function renderRowAnchor(row: ViewRow, density: "week" | "day"): HTMLElement {
const a = document.createElement("a");
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
a.href = rowHref(row);
const dot = document.createElement("span");
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
a.appendChild(dot);
const body = document.createElement("span");
body.className = "views-calendar-row-body";
const title = document.createElement("span");
title.className = "views-calendar-row-title";
title.textContent = row.title;
body.appendChild(title);
const metaParts: string[] = [];
metaParts.push(tDyn("views.kind." + row.kind));
if (row.project_reference) metaParts.push(row.project_reference);
else if (row.project_title) metaParts.push(row.project_title);
if (metaParts.length > 0) {
const meta = document.createElement("span");
meta.className = "views-calendar-row-meta";
meta.textContent = metaParts.join(" · ");
body.appendChild(meta);
}
a.appendChild(body);
return a;
}
function rowHref(row: ViewRow): string {
switch (row.kind) {
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
case "approval_request": return `/inbox`;
case "project_event":
// project_events surface on the project's Verlauf — best we can do
// is link to the project. If no project, leave as a non-link target.
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
}
}
// --- Bucketing / date helpers --------------------------------------------
const WEEKDAY_KEYS: I18nKey[] = [
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
"cal.day.fri", "cal.day.sat", "cal.day.sun",
];
function bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
const out = new Map<string, ViewRow[]>();
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return d;
if (isNaN(d.getTime())) continue;
if (!filter(d)) continue;
const key = isoDate(d);
const arr = out.get(key);
if (arr) arr.push(row);
else out.set(key, [row]);
}
return out;
}
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
const key = isoDate(day);
return rows.filter((r) => {
const d = new Date(r.event_date);
if (isNaN(d.getTime())) return false;
return isoDate(d) === key;
});
}
function startOfWeek(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const offset = (out.getDay() + 6) % 7; // Mon=0
out.setDate(out.getDate() - offset);
return out;
}
function shift(d: Date, view: CalView, dir: number): Date {
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
}
function isToday(d: Date): boolean {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function isoDate(d: Date): string {
@@ -127,3 +466,60 @@ function isoDate(d: Date): string {
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatRangeLabel(view: CalView, anchor: Date): string {
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (view === "month") {
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
}
if (view === "week") {
const start = startOfWeek(anchor);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return formatWeekHeader(start, end, lang);
}
return anchor.toLocaleDateString(lang, {
weekday: "short", year: "numeric", month: "long", day: "numeric",
});
}
function formatWeekHeader(start: Date, end: Date, lang: string): string {
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
return `${startStr} ${endStr}`;
}
// --- URL state -----------------------------------------------------------
function readView(defaultView: CalView | undefined): CalView {
const params = new URLSearchParams(window.location.search);
const raw = params.get(VIEW_PARAM);
if (raw === "month" || raw === "week" || raw === "day") return raw;
return defaultView ?? "month";
}
function readAnchor(rows: ViewRow[]): Date {
const params = new URLSearchParams(window.location.search);
const raw = params.get(DATE_PARAM);
if (raw) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
}
// No URL anchor — pick the first row's date, or today.
for (const row of rows) {
const d = new Date(row.event_date);
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function writeURL(view: CalView, anchor: Date): void {
const url = new URL(window.location.href);
url.searchParams.set(VIEW_PARAM, view);
url.searchParams.set(DATE_PARAM, isoDate(anchor));
history.replaceState(null, "", url.toString());
}

View File

@@ -196,6 +196,12 @@ interface ApprovalDetail {
requester_kind?: "user" | "agent";
decider_name?: string;
decision_note?: string;
// Per-viewer eligibility flags resolved server-side against the caller
// (t-paliad-202). Used to grey out actions the server would reject.
// Optional so an older payload still renders — falsy means "treat as
// disabled" for the safety side (no false enables).
viewer_can_approve?: boolean;
viewer_is_requester?: boolean;
}
function renderApprovalList(rows: ViewRow[]): HTMLElement {
@@ -256,13 +262,15 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// The bar's approval_viewer_role distinguishes which actions are
// appropriate. The surface inspects the active role and decides
// which buttons to keep — but for default rendering we stamp all
// three with role-class hints and let the surface filter.
actions.appendChild(actionBtn("approve"));
actions.appendChild(actionBtn("reject"));
actions.appendChild(actionBtn("revoke"));
// All three actions are stamped on every pending row; the per-viewer
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
// decide which are enabled vs. greyed out with a tooltip. m's ask
// (2026-05-17): show what's possible but disable what isn't, rather
// than alert-after-click. The server still enforces — disabled buttons
// are a UI hint, not a security gate.
actions.appendChild(approvalActionBtn("approve", detail));
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
} else if (detail.status) {
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
@@ -312,16 +320,39 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
return wrap;
}
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
function approvalActionBtn(
action: "approve" | "reject" | "revoke",
detail: ApprovalDetail,
): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.action = action;
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
// approve / reject share the eligibility gate; revoke is requester-only.
const reason = disabledReasonFor(action, detail);
if (reason) {
btn.disabled = true;
btn.title = t(reason);
}
return btn;
}
function disabledReasonFor(
action: "approve" | "reject" | "revoke",
detail: ApprovalDetail,
): I18nKey | null {
if (action === "revoke") {
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
}
// approve + reject — same gate as the server's canApprove.
if (detail.viewer_can_approve) return null;
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
return "approvals.disabled.not_authorized";
}
function formatRelativeTime(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;

View File

@@ -467,6 +467,11 @@ export function paint(
}
// Lane separators — horizontal lines between rows + labels in the gutter.
// Labels live inside <foreignObject> so HTML/CSS handles ellipsis +
// tooltip cleanly. SVG <text> has no auto-clipping and long titles
// would bleed into the chart canvas (t-paliad-211).
const labelPadding = 8;
const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2);
for (let i = 0; i < chart.laneRows.length; i++) {
const row = chart.laneRows[i];
if (i > 0) {
@@ -479,13 +484,19 @@ export function paint(
}));
}
if (row.label) {
const labelEl = svg("text", {
class: "chart-lane-label",
x: 8,
y: row.y + row.height / 2 + 4,
const fo = svg("foreignObject", {
class: "chart-lane-label-fo",
x: labelPadding,
y: row.y,
width: labelMaxWidth,
height: row.height,
});
labelEl.textContent = row.label;
gGrid.appendChild(labelEl);
const div = document.createElement("div");
div.className = "chart-lane-label";
div.textContent = row.label;
div.title = row.label;
fo.appendChild(div);
gGrid.appendChild(fo);
}
}

View File

@@ -7,6 +7,7 @@ import {
} from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
import type { RenderSpec, ViewRow } from "./types";
import { t } from "../i18n";
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
// host for the chart renderer.
@@ -23,6 +24,12 @@ import type { RenderSpec, ViewRow } from "./types";
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
// Zoom levels in ascending span (t-paliad-211). Width-only — the chart's
// existing range presets already provide three meaningful zoom levels.
// Stored in URL as ?tl_zoom=1y|2y|all.
const ZOOM_LEVELS: RangePreset[] = ["1y", "2y", "all"];
const ZOOM_PARAM = "tl_zoom";
export function renderTimelineShape(
host: HTMLElement,
rows: ReadonlyArray<ViewRow>,
@@ -35,21 +42,127 @@ export function renderTimelineShape(
const { events, lanes } = adapt(rows);
const cfg = render.timeline ?? {};
// Resolve the initial zoom: URL > render spec > "1y" default.
const initialZoom = resolveInitialZoom(cfg.range_preset);
// Toolbar lives above the chart in its own row so it doesn't compete
// with the date-axis / lane labels for space.
const toolbar = document.createElement("div");
toolbar.className = "views-timeline-toolbar";
host.appendChild(toolbar);
const chartHost = document.createElement("div");
chartHost.className = "views-timeline-chart-host-inner";
host.appendChild(chartHost);
// The CV adapter has no per-project "id" to fetch live timeline data
// for — we hand mount() a placeholder projectId and the staticData
// pre-loaded array so it skips the project endpoint entirely. If the
// user clicks a mark, the renderer's default click handler still
// resolves /deadlines/{id} / /appointments/{id} from the adapted
// event's id field, so deep-links land on the correct entity page.
return mount(host, {
const handle = mount(chartHost, {
projectId: "cv",
staticData: { events, lanes },
palette: (cfg.palette as Palette | undefined) ?? "default",
density: (cfg.density as Density | undefined) ?? "standard",
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
rangePreset: initialZoom,
rangeFrom: cfg.range_from,
rangeTo: cfg.range_to,
});
let currentZoom = initialZoom;
const setZoom = (next: RangePreset) => {
if (next === currentZoom) return;
currentZoom = next;
handle.setRange(next);
writeZoomURL(next);
paintToolbar();
};
const paintToolbar = () => {
toolbar.innerHTML = "";
const zoomGroup = document.createElement("div");
zoomGroup.className = "views-timeline-zoom-group";
const zoomLabel = document.createElement("span");
zoomLabel.className = "views-timeline-zoom-label";
zoomLabel.textContent = t("views.timeline.zoom.label");
zoomGroup.appendChild(zoomLabel);
const zoomOut = document.createElement("button");
zoomOut.type = "button";
zoomOut.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomOut.setAttribute("aria-label", t("views.timeline.zoom.out"));
zoomOut.title = t("views.timeline.zoom.out");
zoomOut.textContent = "";
zoomOut.disabled = currentZoom === ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
zoomOut.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[idx + 1]);
});
zoomGroup.appendChild(zoomOut);
// Active-level chips (1y / 2y / all). Clicking jumps directly.
const chips = document.createElement("div");
chips.className = "views-timeline-zoom-chips agenda-chip-row";
for (const level of ZOOM_LEVELS) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "agenda-chip views-timeline-zoom-chip"
+ (level === currentZoom ? " agenda-chip-active" : "");
chip.dataset.zoom = level;
chip.textContent = t(zoomLevelKey(level));
chip.addEventListener("click", () => setZoom(level));
chips.appendChild(chip);
}
zoomGroup.appendChild(chips);
const zoomIn = document.createElement("button");
zoomIn.type = "button";
zoomIn.className = "btn-secondary btn-small views-timeline-zoom-btn";
zoomIn.setAttribute("aria-label", t("views.timeline.zoom.in"));
zoomIn.title = t("views.timeline.zoom.in");
zoomIn.textContent = "+";
zoomIn.disabled = currentZoom === ZOOM_LEVELS[0];
zoomIn.addEventListener("click", () => {
const idx = ZOOM_LEVELS.indexOf(currentZoom);
if (idx > 0) setZoom(ZOOM_LEVELS[idx - 1]);
});
zoomGroup.appendChild(zoomIn);
toolbar.appendChild(zoomGroup);
};
paintToolbar();
// Apply the URL zoom if it differed from the spec — mount() already
// used initialZoom so this is a no-op when URL was empty. But when URL
// disagreed with the spec, mount() honoured the URL and the toolbar
// already reflects that, so nothing extra to do here.
return handle;
}
function zoomLevelKey(level: RangePreset): "views.timeline.zoom.1y" | "views.timeline.zoom.2y" | "views.timeline.zoom.all" {
if (level === "1y") return "views.timeline.zoom.1y";
if (level === "2y") return "views.timeline.zoom.2y";
return "views.timeline.zoom.all";
}
function resolveInitialZoom(spec: string | undefined): RangePreset {
const params = new URLSearchParams(window.location.search);
const raw = params.get(ZOOM_PARAM);
if (raw && (ZOOM_LEVELS as string[]).includes(raw)) return raw as RangePreset;
if (spec && (ZOOM_LEVELS as string[]).includes(spec)) return spec as RangePreset;
return "1y";
}
function writeZoomURL(zoom: RangePreset): void {
const url = new URL(window.location.href);
url.searchParams.set(ZOOM_PARAM, zoom);
history.replaceState(null, "", url.toString());
}
export interface AdapterResult {

View File

@@ -38,6 +38,14 @@ export interface CalculatedDeadline {
priority: "mandatory" | "recommended" | "optional" | "informational";
ruleRef: string;
legalSource?: string;
// legalSourceDisplay is the pretty form ("UPC RoP R.220(1)") produced
// by FormatLegalSourceDisplay on the backend. Renderer prefers this
// over ruleRef when set; falls back to ruleRef otherwise.
legalSourceDisplay?: string;
// legalSourceURL is the youpc.org/laws permalink when the cited body
// is hosted there (UPCRoP / UPCA / UPCS today). Empty for DE/EPA/EU
// bodies — the renderer shows display text without a link.
legalSourceURL?: string;
notes?: string;
notesEN?: string;
dueDate: string;
@@ -211,6 +219,13 @@ export interface CardOpts {
// verfahrensablauf abstract-browse surface keeps editable=false because
// there's no anchor-override state on that page in Slice 1.
editable?: boolean;
// showNotes controls how the per-rule descriptive notes render:
// true → expanded `<div class="timeline-notes">…</div>` below the card
// false → compact ⓘ icon next to the meta line, full text on hover
// (browser-native `title` attribute) and screen-reader-readable
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
// re-renders. Default false — notes are noisy on long timelines.
showNotes?: boolean;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
@@ -240,19 +255,35 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
: "";
const ruleRef = dl.ruleRef
? `<span class="timeline-rule">${dl.ruleRef}</span>`
: "";
// Prefer the structured legalSource (pretty display + youpc.org link
// when hosted there) over the bare rule_code fallback. UPC.RoP rules
// link to /laws/UPCRoP/<n>; DE / EPA / EU bodies have no youpc home
// yet so we render display text plain.
const legalDisplay = dl.legalSourceDisplay || "";
const legalURL = dl.legalSourceURL || "";
let ruleRef = "";
if (legalDisplay && legalURL) {
ruleRef = `<a class="timeline-rule timeline-rule--link" href="${escAttr(legalURL)}" target="_blank" rel="noopener noreferrer">${escHtml(legalDisplay)}</a>`;
} else if (legalDisplay) {
ruleRef = `<span class="timeline-rule">${escHtml(legalDisplay)}</span>`;
} else if (dl.ruleRef) {
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
}
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const notes = noteText
const showNotes = opts.showNotes === true;
const notesBlock = noteText && showNotes
? `<div class="timeline-notes">${noteText}</div>`
: "";
const noteHint = noteText && !showNotes
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
: "";
const meta = (opts.showParty || ruleRef)
const meta = (opts.showParty || ruleRef || noteHint)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${ruleRef}
${noteHint}
</div>`
: "";
@@ -265,7 +296,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
</div>
${meta}
${adjustedNote}
${notes}`;
${notesBlock}`;
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
@@ -339,7 +370,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
@@ -413,23 +444,23 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
const courtCache = new Map<string, CourtRow[]>();
export function courtTypesFor(proceedingType: string): string[] {
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
return ["UPC-CoA"];
}
if (proceedingType === "UPC_REV") {
if (proceedingType === "upc.rev.cfi") {
return ["UPC-CD", "UPC-LD"];
}
if (proceedingType.startsWith("UPC_")) {
if (proceedingType.startsWith("upc.")) {
return ["UPC-LD"];
}
return [];
}
export function defaultCourtFor(proceedingType: string): string {
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
return "upc-coa-luxembourg";
}
if (proceedingType === "UPC_REV") {
if (proceedingType === "upc.rev.cfi") {
return "upc-cd-paris";
}
return "upc-ld-muenchen";

View File

@@ -64,28 +64,28 @@ export function ProjectFormFields(): string {
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (7 Ziffern)</label>
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (6 Ziffern)</label>
<input
type="text"
id="project-client-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0001234"
pattern="[0-9]{6}"
maxLength={6}
placeholder="001234"
/>
</div>
<div className="form-field">
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (7 Ziffern)</label>
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (6 Ziffern)</label>
<input
type="text"
id="project-matter-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0000567"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000567"
/>
</div>
</div>
<p className="form-hint" data-i18n="projects.field.clientmatter.hint">
{`${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt
{`${FIRM}-Billing-Nummern. Format CCCCCC.MMMMMM. Client-Nr. wird an Unterprojekte vererbt
(überschreibbar).`}
</p>

View File

@@ -54,34 +54,44 @@ function quickChip(c: QuickChip): string {
}
const UPC_TYPES: ProceedingDef[] = [
{ code: "UPC_INF", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
{ code: "UPC_PI", i18nKey: "deadlines.upc_pi", name: "Einstw. Ma\u00dfnahmen" },
{ code: "UPC_APP", i18nKey: "deadlines.upc_app", name: "Berufung" },
{ code: "UPC_DAMAGES", i18nKey: "deadlines.upc_damages", name: "Schadensbemessung" },
{ code: "UPC_DISCOVERY", i18nKey: "deadlines.upc_discovery", name: "Bucheinsicht" },
{ code: "UPC_COST_APPEAL", i18nKey: "deadlines.upc_cost_appeal", name: "Berufung Kosten" },
{ code: "UPC_APP_ORDERS", i18nKey: "deadlines.upc_app_orders", name: "Berufung Anordnungen" },
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Ma\u00dfnahmen" },
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
const DE_TYPES: ProceedingDef[] = [
{ code: "DE_INF", i18nKey: "deadlines.de_inf", name: "Verletzungsklage (LG)" },
{ code: "DE_INF_OLG", i18nKey: "deadlines.de_inf_olg", name: "Berufung OLG" },
{ code: "DE_INF_BGH", i18nKey: "deadlines.de_inf_bgh", name: "Revision/NZB BGH" },
{ code: "DE_NULL", i18nKey: "deadlines.de_null", name: "Nichtigkeitsverfahren" },
{ code: "DE_NULL_BGH", i18nKey: "deadlines.de_null_bgh", name: "Berufung BGH (Nichtigk.)" },
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
// so a user scanning the picker sees the instance-and-role at a glance
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
// verfahren". Sub-group headers convey the type grouping. Combined-
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "EPA_OPP", i18nKey: "deadlines.epa_opp", name: "Einspruchsverfahren" },
{ code: "EPA_APP", i18nKey: "deadlines.epa_app", name: "Beschwerdeverfahren" },
{ code: "EP_GRANT", i18nKey: "deadlines.ep_grant", name: "EP-Erteilungsverfahren" },
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "DPMA_OPP", i18nKey: "deadlines.dpma_opp", name: "Einspruch DPMA" },
{ code: "DPMA_BPATG_BESCHWERDE", i18nKey: "deadlines.dpma_bpatg_beschwerde", name: "Beschwerde BPatG (DPMA)" },
{ code: "DPMA_BGH_RB", i18nKey: "deadlines.dpma_bgh_rb", name: "Rechtsbeschwerde BGH" },
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
export function renderFristenrechner(): string {
@@ -424,8 +434,17 @@ export function renderFristenrechner(): string {
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
@@ -527,6 +546,10 @@ export function renderFristenrechner(): string {
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
</div>
<div id="timeline-container">

View File

@@ -268,12 +268,13 @@ export type I18nKey =
| "admin.partner_units.new.heading"
| "admin.partner_units.subtitle"
| "admin.partner_units.title"
| "admin.rules.col.code"
| "admin.rules.col.legal_citation"
| "admin.rules.col.lifecycle"
| "admin.rules.col.modified"
| "admin.rules.col.name"
| "admin.rules.col.priority"
| "admin.rules.col.proceeding"
| "admin.rules.col.submission_code"
| "admin.rules.edit.action.archive"
| "admin.rules.edit.action.archive.error"
| "admin.rules.edit.action.archive.ok"
@@ -309,7 +310,6 @@ export type I18nKey =
| "admin.rules.edit.field.alt_duration_value"
| "admin.rules.edit.field.alt_rule_code"
| "admin.rules.edit.field.anchor_alt"
| "admin.rules.edit.field.code"
| "admin.rules.edit.field.combine_op"
| "admin.rules.edit.field.concept"
| "admin.rules.edit.field.condition.valid"
@@ -335,6 +335,7 @@ export type I18nKey =
| "admin.rules.edit.field.spawn_label"
| "admin.rules.edit.field.spawn_proceeding"
| "admin.rules.edit.field.spawn_proceeding.none"
| "admin.rules.edit.field.submission_code"
| "admin.rules.edit.field.timing"
| "admin.rules.edit.field.trigger"
| "admin.rules.edit.field.trigger.none"
@@ -591,6 +592,9 @@ export type I18nKey =
| "approvals.decision_kind.peer"
| "approvals.diff.after"
| "approvals.diff.before"
| "approvals.disabled.not_authorized"
| "approvals.disabled.revoke_not_requester"
| "approvals.disabled.self_approval"
| "approvals.empty.mine"
| "approvals.empty.pending_mine"
| "approvals.entity.appointment"
@@ -649,8 +653,13 @@ export type I18nKey =
| "bottomnav.add.title"
| "bottomnav.badge.deadlines"
| "bottomnav.menu"
| "cal.day.back_to_month"
| "cal.day.fri"
| "cal.day.mon"
| "cal.day.next"
| "cal.day.no_entries"
| "cal.day.open_day"
| "cal.day.prev"
| "cal.day.sat"
| "cal.day.sun"
| "cal.day.thu"
@@ -668,6 +677,13 @@ export type I18nKey =
| "cal.month.7"
| "cal.month.8"
| "cal.month.9"
| "cal.month.next"
| "cal.month.prev"
| "cal.view.day"
| "cal.view.month"
| "cal.view.week"
| "cal.week.next"
| "cal.week.prev"
| "caldav.delete"
| "caldav.delete.confirm"
| "caldav.delete.done"
@@ -909,16 +925,19 @@ export type I18nKey =
| "deadlines.col.status"
| "deadlines.col.title"
| "deadlines.complete.action"
| "deadlines.complete.confirm"
| "deadlines.court.indirect"
| "deadlines.court.label"
| "deadlines.court.set"
| "deadlines.date.edit.hint"
| "deadlines.de"
| "deadlines.de_inf"
| "deadlines.de_inf_bgh"
| "deadlines.de_inf_olg"
| "deadlines.de_null"
| "deadlines.de_null_bgh"
| "deadlines.de.group.inf"
| "deadlines.de.group.null"
| "deadlines.de.inf.bgh"
| "deadlines.de.inf.lg"
| "deadlines.de.inf.olg"
| "deadlines.de.null.bgh"
| "deadlines.de.null.bpatg"
| "deadlines.detail.back"
| "deadlines.detail.cancel"
| "deadlines.detail.complete"
@@ -941,16 +960,16 @@ export type I18nKey =
| "deadlines.detail.source"
| "deadlines.detail.title"
| "deadlines.dpma"
| "deadlines.dpma_bgh_rb"
| "deadlines.dpma_bpatg_beschwerde"
| "deadlines.dpma_opp"
| "deadlines.dpma.appeal.bgh"
| "deadlines.dpma.appeal.bpatg"
| "deadlines.dpma.opp.dpma"
| "deadlines.empty.filtered"
| "deadlines.empty.hint"
| "deadlines.empty.title"
| "deadlines.ep_grant"
| "deadlines.epa"
| "deadlines.epa_app"
| "deadlines.epa_opp"
| "deadlines.epa.grant.exa"
| "deadlines.epa.opp.boa"
| "deadlines.epa.opp.opd"
| "deadlines.error.generic"
| "deadlines.error.required"
| "deadlines.event.adjusted"
@@ -1050,6 +1069,7 @@ export type I18nKey =
| "deadlines.neu.submit"
| "deadlines.neu.subtitle"
| "deadlines.neu.title"
| "deadlines.notes.show"
| "deadlines.optional.badge"
| "deadlines.party.both"
| "deadlines.party.both.label"
@@ -1187,14 +1207,15 @@ export type I18nKey =
| "deadlines.trigger.label"
| "deadlines.unavailable"
| "deadlines.upc"
| "deadlines.upc_app"
| "deadlines.upc_app_orders"
| "deadlines.upc_cost_appeal"
| "deadlines.upc_damages"
| "deadlines.upc_discovery"
| "deadlines.upc_inf"
| "deadlines.upc_pi"
| "deadlines.upc_rev"
| "deadlines.upc.apl.cost"
| "deadlines.upc.apl.merits"
| "deadlines.upc.apl.order"
| "deadlines.upc.ccr.cfi"
| "deadlines.upc.disc.cfi"
| "deadlines.upc.dmgs.cfi"
| "deadlines.upc.inf.cfi"
| "deadlines.upc.pi.cfi"
| "deadlines.upc.rev.cfi"
| "deadlines.urgency.later"
| "deadlines.urgency.overdue"
| "deadlines.urgency.soon"
@@ -2415,6 +2436,12 @@ export type I18nKey =
| "views.source.project_event"
| "views.subtitle"
| "views.timeline.caveat.body"
| "views.timeline.zoom.1y"
| "views.timeline.zoom.2y"
| "views.timeline.zoom.all"
| "views.timeline.zoom.in"
| "views.timeline.zoom.label"
| "views.timeline.zoom.out"
| "views.title"
| "views.toast.inaccessible_n"
| "views.toast.inaccessible_one";

View File

@@ -188,7 +188,7 @@ export function renderProjectsDetail(): string {
<div className="form-field">
<label htmlFor="smart-timeline-counterclaim-procedure" data-i18n="projects.detail.smarttimeline.counterclaim.procedure">Verfahrenstyp</label>
<select id="smart-timeline-counterclaim-procedure">
{/* Options injected from client; defaults to UPC_REV */}
{/* Options injected from client; defaults to upc.rev.cfi */}
</select>
</div>
<div className="form-field">

View File

@@ -3075,6 +3075,25 @@ input[type="range"]::-moz-range-thumb {
margin-bottom: 0.5rem;
}
/* Sub-group inside a .proceeding-group — used today by the DE block
to split Verletzungsverfahren tiles from Nichtigkeitsverfahren tiles
under one "Deutsche Gerichte" h4. Heading is one tier below the h4
(mixed-case, no upper-tracking) so the two-level hierarchy reads at
a glance. */
.proceeding-subgroup {
margin-top: 0.6rem;
}
.proceeding-subgroup:first-child {
margin-top: 0;
}
.proceeding-subgroup-heading {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text);
margin: 0 0 0.35rem 0;
}
.proceeding-btns {
display: flex;
@@ -3128,7 +3147,7 @@ input[type="range"]::-moz-range-thumb {
gap: 0.75rem;
}
/* Nested checkbox under a parent flag (e.g. UPC_INF inf-amend-flag is
/* Nested checkbox under a parent flag (e.g. upc.inf.cfi inf-amend-flag is
only meaningful with ccr-flag on — indent so the dependency is
visible). */
.date-field-row--nested {
@@ -3422,6 +3441,49 @@ input[type="range"]::-moz-range-thumb {
cursor: pointer;
}
/* Notes toggle — checkbox affordance in the view-toggle bar that flips
per-card descriptive notes between compact (ⓘ tooltip icon) and
expanded (timeline-notes block). Sits with a leading separator so it
reads as a distinct control from the radio view picker. */
.fristen-notes-option {
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
color: var(--color-text);
margin-left: auto;
padding-left: 0.75rem;
border-left: 1px solid var(--color-border);
}
.fristen-notes-option input[type=checkbox] {
margin: 0;
cursor: pointer;
}
/* Compact note hint — sits in the timeline-meta line when the notes
toggle is off. Native browser tooltip via title= attribute carries
the full text on hover; tabindex=0 + aria-label make it
keyboard / screen-reader accessible. */
.timeline-note-hint {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
font-size: 0.85rem;
color: var(--color-text-muted);
cursor: help;
user-select: none;
}
.timeline-note-hint:hover,
.timeline-note-hint:focus-visible {
color: var(--color-text);
outline: none;
}
/* Fristenrechner — three-column lane view (Proactive | Court | Reactive).
Each lane is independently date-ordered; party=both rows render below
as full-width spans because they apply to all sides. */
@@ -11456,6 +11518,24 @@ dialog.quick-add-sheet::backdrop {
font-size: 13px;
}
/* Greyed-out variant for actions the current viewer can't grant —
* approve/reject without authority, revoke when not the requester
* (t-paliad-202). Click is suppressed by the disabled attribute; the
* tooltip on `title` explains why. The neutral background/colour pair
* overrides .btn-primary/.btn-danger/.btn-secondary so all three
* variants look the same when disabled. */
.inbox-row-action:disabled {
cursor: not-allowed;
opacity: 0.55;
background: var(--color-surface-2);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.inbox-row-action:disabled:hover {
background: var(--color-surface-2);
border-color: var(--color-border);
}
.inbox-row-decided {
color: var(--fg-muted);
font-size: 12px;
@@ -11755,16 +11835,58 @@ dialog.quick-add-sheet::backdrop {
color: var(--color-text-muted);
}
/* shape=calendar. */
/* shape=calendar. month / week / day views share .views-calendar wrapper;
the variant class .views-calendar--<view> drives any per-view tweaks. */
.views-calendar-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.views-calendar-view-switcher {
display: inline-flex;
gap: 4px;
}
.views-calendar-nav {
display: inline-flex;
align-items: center;
gap: 8px;
}
.views-calendar-nav-btn {
min-width: 32px;
padding: 4px 8px;
}
.views-calendar-nav-label {
font-weight: 600;
min-width: 12ch;
text-align: center;
}
.views-calendar-back-to-month {
background: transparent;
border: none;
color: var(--color-link, var(--color-accent));
cursor: pointer;
padding: 4px 8px;
font-size: 13px;
text-decoration: underline;
}
.views-calendar-back-to-month:hover {
color: var(--color-link-hover, var(--color-accent));
}
.views-calendar-month-label {
font-size: 18px;
margin: 0 0 12px 0;
}
.views-calendar-weekdays {
/* Month view — one grid contains both the weekday header row and the day
cells, so they share the same column template (no drift). */
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.views-calendar-weekday {
font-size: 12px;
@@ -11772,11 +11894,7 @@ dialog.quick-add-sheet::backdrop {
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 4px;
}
.views-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
text-align: center;
}
.views-calendar-cell {
min-height: 80px;
@@ -11785,15 +11903,32 @@ dialog.quick-add-sheet::backdrop {
border-radius: 4px;
background: var(--color-surface);
color: var(--color-text);
display: flex;
flex-direction: column;
gap: 4px;
}
.views-calendar-cell--out {
background: transparent;
border: 1px dashed var(--color-border);
}
.views-calendar-cell--today {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.views-calendar-cell-day {
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 4px;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
align-self: flex-start;
font-weight: 600;
}
.views-calendar-cell-day:hover,
.views-calendar-cell-day:focus-visible {
color: var(--color-text);
text-decoration: underline;
}
.views-calendar-pills {
list-style: none;
@@ -11812,12 +11947,147 @@ dialog.quick-add-sheet::backdrop {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
text-decoration: none;
border: none;
text-align: left;
width: 100%;
cursor: pointer;
}
.views-calendar-pill:hover {
background: var(--color-surface-hover, var(--color-surface-muted));
}
.views-calendar-pill--more {
color: var(--color-text-muted);
text-align: center;
background: transparent;
}
/* Week view — 7 columns, scrollable per column when overflowing. */
.views-calendar-week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.views-calendar-week-column {
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface);
display: flex;
flex-direction: column;
min-height: 200px;
max-height: 70vh;
overflow: hidden;
}
.views-calendar-week-column--today {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.views-calendar-week-head {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-muted);
font-size: 12px;
}
.views-calendar-week-dow {
text-transform: uppercase;
color: var(--color-text-muted);
letter-spacing: 0.05em;
}
.views-calendar-week-dnum {
font-weight: 600;
color: var(--color-text);
}
.views-calendar-week-list {
list-style: none;
margin: 0;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
flex: 1 1 auto;
}
.views-calendar-week-empty {
margin: 0;
padding: 12px 8px;
color: var(--color-text-muted);
font-size: 12px;
text-align: center;
}
/* Day view — single chronological list. */
.views-calendar-day-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.views-calendar-day-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.views-calendar-day-empty {
margin: 0;
padding: 16px;
color: var(--color-text-muted);
font-style: italic;
text-align: center;
}
/* Row anchors used by both week and day views. */
.views-calendar-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-surface);
text-decoration: none;
color: var(--color-text);
}
.views-calendar-row:hover {
background: var(--color-surface-hover, var(--color-surface-muted));
}
.views-calendar-row-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
flex: 0 0 8px;
background: var(--color-text-muted);
}
.views-calendar-row-dot--deadline { background: var(--color-accent); }
.views-calendar-row-dot--appointment { background: #3b82f6; }
.views-calendar-row-dot--project_event { background: #a855f7; }
.views-calendar-row-dot--approval_request { background: #f59e0b; }
.views-calendar-row-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.views-calendar-row-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-calendar-row-meta {
font-size: 12px;
color: var(--color-text-muted);
}
.views-calendar-row--week .views-calendar-row-title {
white-space: normal;
}
.views-calendar-mobile-notice {
margin: 0 0 12px 0;
font-size: 12px;
@@ -14585,7 +14855,14 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-chart .chart-lane-label {
font-size: 0.85rem;
font-weight: 500;
fill: var(--chart-lane-label);
color: var(--chart-lane-label);
height: 100%;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: default;
}
.smart-timeline-chart .chart-today-rule {
stroke: var(--chart-today-rule);
@@ -14695,6 +14972,45 @@ dialog.quick-add-sheet::backdrop {
outline-offset: 2px;
}
/* Custom Views timeline toolbar (t-paliad-211) — zoom controls above the
chart canvas. Stays in flow so it doesn't overlap the SVG date axis. */
.views-timeline-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.views-timeline-zoom-group {
display: inline-flex;
align-items: center;
gap: 8px;
}
.views-timeline-zoom-label {
font-size: 12px;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.views-timeline-zoom-btn {
min-width: 32px;
padding: 4px 10px;
font-weight: 600;
}
.views-timeline-zoom-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.views-timeline-zoom-chips {
display: inline-flex;
gap: 4px;
}
.views-timeline-chart-host-inner {
/* Reserve a min-height so the loading placeholder doesn't collapse
and the toolbar/chart stack stays predictable. */
min-height: 200px;
}
/* ---- Palette presets (t-paliad-177 Slice 2, design §5.1) ----
Each palette is a pure data-attribute swap of the --chart-* tokens.
Renderer code never reads palette state — it just emits classed SVG

View File

@@ -29,34 +29,44 @@ function proceedingBtn(p: ProceedingDef): string {
}
const UPC_TYPES: ProceedingDef[] = [
{ code: "UPC_INF", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
{ code: "UPC_PI", i18nKey: "deadlines.upc_pi", name: "Einstw. Maßnahmen" },
{ code: "UPC_APP", i18nKey: "deadlines.upc_app", name: "Berufung" },
{ code: "UPC_DAMAGES", i18nKey: "deadlines.upc_damages", name: "Schadensbemessung" },
{ code: "UPC_DISCOVERY", i18nKey: "deadlines.upc_discovery", name: "Bucheinsicht" },
{ code: "UPC_COST_APPEAL", i18nKey: "deadlines.upc_cost_appeal", name: "Berufung Kosten" },
{ code: "UPC_APP_ORDERS", i18nKey: "deadlines.upc_app_orders", name: "Berufung Anordnungen" },
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
const DE_TYPES: ProceedingDef[] = [
{ code: "DE_INF", i18nKey: "deadlines.de_inf", name: "Verletzungsklage (LG)" },
{ code: "DE_INF_OLG", i18nKey: "deadlines.de_inf_olg", name: "Berufung OLG" },
{ code: "DE_INF_BGH", i18nKey: "deadlines.de_inf_bgh", name: "Revision/NZB BGH" },
{ code: "DE_NULL", i18nKey: "deadlines.de_null", name: "Nichtigkeitsverfahren" },
{ code: "DE_NULL_BGH", i18nKey: "deadlines.de_null_bgh", name: "Berufung BGH (Nichtigk.)" },
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
// so a user scanning the picker sees the instance-and-role at a glance
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
// verfahren". Sub-group headers convey the type grouping. Combined-
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "EPA_OPP", i18nKey: "deadlines.epa_opp", name: "Einspruchsverfahren" },
{ code: "EPA_APP", i18nKey: "deadlines.epa_app", name: "Beschwerdeverfahren" },
{ code: "EP_GRANT", i18nKey: "deadlines.ep_grant", name: "EP-Erteilungsverfahren" },
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "DPMA_OPP", i18nKey: "deadlines.dpma_opp", name: "Einspruch DPMA" },
{ code: "DPMA_BPATG_BESCHWERDE", i18nKey: "deadlines.dpma_bpatg_beschwerde", name: "Beschwerde BPatG (DPMA)" },
{ code: "DPMA_BGH_RB", i18nKey: "deadlines.dpma_bgh_rb", name: "Rechtsbeschwerde BGH" },
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
export function renderVerfahrensablauf(): string {
@@ -107,8 +117,17 @@ export function renderVerfahrensablauf(): string {
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
@@ -155,6 +174,35 @@ export function renderVerfahrensablauf(): string {
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select>
</div>
{/* Proceeding-specific flag rows — mirror /tools/fristenrechner
so an abstract-browse user can model the same variants
(CCR, Patentänderung, Verletzungswiderklage,
Vorab-Einrede). Show/hide driven by selectedType in
the client. */}
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen
</button>
@@ -177,6 +225,10 @@ export function renderVerfahrensablauf(): string {
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
</div>
<div id="timeline-container">

View File

@@ -60,6 +60,13 @@ export function renderViews(): string {
</a>
</div>
{/* Filter bar host — t-paliad-211. mountFilterBar appends its
own toolbar element here; the saved view's filter_spec
becomes the bar's baseline, axes are chosen client-side
per the view's data sources. */}
<div className="views-filter-bar" id="views-filter-bar" hidden />
{/* Empty / onboarding state — shown on bare /views with no saved views. */}
<div className="views-onboarding" id="views-onboarding" hidden>
<h2 data-i18n="views.onboarding.title">Eigene Ansichten &mdash; was ist das?</h2>

View File

@@ -0,0 +1,67 @@
-- t-paliad-200 down — reverses 093_retire_litigation_category.up.sql.
--
-- Restores the 7 litigation-category paliad.proceeding_types rows from
-- the _pre_093 snapshot, moves the 40 archived deadline_rules back onto
-- their original proceeding_type_id values (and reverts
-- lifecycle_state + is_active to their pre-093 values), then drops the
-- _archived_litigation holding pt.
--
-- The snapshot tables themselves stay — they're the source of this
-- rollback's data and a permanent audit artefact. A focused
-- follow-up drops the snapshots once Slice 9 is verified in prod.
SELECT set_config(
'paliad.audit_reason',
'rollback 093: restore litigation proceeding_types + un-archive the 40 Pipeline-A rules from pre-093 snapshots',
true);
-- =============================================================================
-- 1. Restore the 7 litigation proceeding_types rows. ON CONFLICT (id)
-- DO NOTHING — if a row somehow survived the up migration we don't
-- clobber it.
-- =============================================================================
INSERT INTO paliad.proceeding_types
(id, code, name, description, jurisdiction, category,
default_color, sort_order, is_active, name_en, display_order)
SELECT id, code, name, description, jurisdiction, category,
default_color, sort_order, is_active, name_en, display_order
FROM paliad.proceeding_types_pre_093
ON CONFLICT (id) DO NOTHING;
-- Re-align the proceeding_types_id_seq if a SERIAL/IDENTITY column
-- bumped past the restored ids. The pre-093 max was 7; the
-- _archived_litigation INSERT in the up migration claimed a later id.
-- Setting the seq to the max of the live table keeps future INSERTs
-- safe regardless of order.
SELECT setval(
pg_get_serial_sequence('paliad.proceeding_types', 'id'),
GREATEST(
(SELECT COALESCE(MAX(id), 1) FROM paliad.proceeding_types),
1
)
);
-- =============================================================================
-- 2. Restore the 40 deadline_rules rows to their pre-093 state:
-- proceeding_type_id, lifecycle_state, is_active, updated_at. The
-- rule UUIDs are stable so we match on id. The mig 079 audit
-- trigger captures these UPDATEs as the rollback record.
-- =============================================================================
UPDATE paliad.deadline_rules dr
SET proceeding_type_id = snap.proceeding_type_id,
lifecycle_state = snap.lifecycle_state,
is_active = snap.is_active,
updated_at = snap.updated_at
FROM paliad.deadline_rules_pre_093 snap
WHERE dr.id = snap.id;
-- =============================================================================
-- 3. Drop the _archived_litigation holding pt. Safe — step 2 moved all
-- 40 rules off it. The CASCADE is a no-op (FK on rules has
-- ON DELETE CASCADE, but there are zero rules to cascade).
-- =============================================================================
DELETE FROM paliad.proceeding_types
WHERE code = '_archived_litigation';

View File

@@ -0,0 +1,247 @@
-- t-paliad-200 / Fristen Phase 3 Slice 9 follow-up B — retire the
-- 'litigation' category from the rule corpus.
--
-- Lorenz's Slice 9 (t-paliad-195) deferred this drop because 40 active
-- paliad.deadline_rules still pointed at the 7 litigation-category
-- proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). Phase 3
-- Slice 5 retired litigation codes from project-binding (mig 087/088);
-- this migration retires them from the rule corpus.
--
-- Plan choice (audit-gated, paliadin-approved): archive-all-40 rather
-- than the original re-parent plan. The audit found:
--
-- * 23 of 40 Pipeline-A litigation rules share their `code` with an
-- existing fristenrechner rule on the proposed re-parent target
-- (e.g. `inf.oral` exists on both INF and UPC_INF). Re-parenting
-- would leave two rules with identical (proceeding_type_id, code),
-- breaking the implicit per-proceeding rule_code identity contract
-- keyed off by projection / search / rule_editor.
-- * The fristenrechner-category rules are the production version:
-- proper German names, legal_source pinned (UPC.RoP citations),
-- full bilateral chains, intra-proceeding counterclaim handling
-- via inf.def_to_ccr / rev.cc_inf / etc. The Pipeline-A rules are
-- stubs: English-only, mostly NULL legal_source, duration_value=0
-- for 28 of 40, no spawn_proceeding_type_id wiring.
-- * 1 live deadline ("Lecker Frist", status=completed) points at
-- Pipeline-A inf.rejoin/INF via paliad.deadlines.rule_id. Archive-
-- not-delete preserves the FK.
-- * 30 intra-litigation parent_id chains would be silently broken by
-- piecemeal re-parenting. Archive-all preserves them.
-- * FK on deadline_rules.proceeding_type_id is ON DELETE CASCADE →
-- proceeding_types(id). A naive DELETE of the 7 litigation rows
-- would cascade-delete all 40 rules AND break the live deadline's
-- rule_id FK. Rules must be moved off the litigation pt ids before
-- the litigation rows are dropped.
--
-- Surfaced for legal review at merge (commit body lists these so they
-- don't get lost as the four open coverage questions Phase 3 leaves
-- behind):
--
-- 1. inf.prelim (Preliminary Objection, RoP 19, 1 month) — not
-- present on UPC_INF. Possible coverage gap for the fristenrechner
-- ruleset; legal review to decide whether to add it.
-- 2. inf.appeal / rev.appeal / ccr.appeal as cross-proceeding spawns
-- into UPC_APP (2 months, UPC.RoP.220.1) — fristenrechner UPC_APP
-- currently starts standalone with no spawn from UPC_INF/UPC_REV.
-- Possible UX gap; the Pipeline-A versions had
-- spawn_proceeding_type_id=NULL so they weren't functional
-- spawns either.
-- 3. ccr.amend / rev.amend (spawn rules) — superseded by
-- inf.app_to_amend / rev.app_to_amend on UPC_INF / UPC_REV. Safe
-- to drop.
-- 4. zpo.klage / zpo.vertanz / zpo.klageerw / zpo.berufung — no UPC
-- analogue; redundant with DE_INF / DE_INF_OLG / DE_INF_BGH and
-- DE_NULL / DE_NULL_BGH. Safe to drop.
--
-- Sequencing — every step required for the drop to be safe:
--
-- 1. Snapshot paliad.proceeding_types and the 40 affected
-- paliad.deadline_rules into _pre_093 audit tables.
-- 2. Create a holding proceeding_type `_archived_litigation`
-- (category='archived', is_active=false, jurisdiction='UPC') to
-- home the archived rules and preserve their intra-set parent_id
-- chains across the drop.
-- 3. UPDATE all 40 rules: proceeding_type_id = archived_id,
-- lifecycle_state='archived', is_active=false. The mig 079
-- trigger captures every row in paliad.deadline_rule_audit.
-- 4. DELETE the 7 litigation rows from paliad.proceeding_types
-- (now safe — nothing references them).
-- 5. Hard assertions: zero rules on litigation ids, zero litigation
-- rows surviving, exactly 40 rules on the archive id.
--
-- Idempotent: re-applying is a no-op (snapshots use CREATE TABLE IF
-- NOT EXISTS; the archive pt INSERT uses ON CONFLICT DO NOTHING; the
-- UPDATEs are guarded by lifecycle_state='archived' so they only fire
-- once; the DELETE targets category='litigation' which becomes empty
-- after first run).
--
-- audit_reason wrapper at top — the mig 079 trigger on
-- paliad.deadline_rules logs every row-level edit. The UPDATE on all
-- 40 rules fires through that trigger, so the reason persists in
-- paliad.deadline_rule_audit for forever-grade audit.
SELECT set_config(
'paliad.audit_reason',
'mig 093: retire litigation category from rule corpus — archive 40 Pipeline-A rules under _archived_litigation pt, drop 7 litigation proceeding_types rows (t-paliad-200, Slice 9 follow-up B)',
true);
-- =============================================================================
-- 1. Backup snapshots. CREATE TABLE IF NOT EXISTS keeps the migration
-- idempotent across reapplications. Snapshots persist post-drop as
-- the permanent audit anchor; the down migration restores from them.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.proceeding_types_pre_093 AS
SELECT *, now() AS snapshotted_at
FROM paliad.proceeding_types
WHERE category = 'litigation';
COMMENT ON TABLE paliad.proceeding_types_pre_093 IS
'Snapshot of the 7 litigation-category paliad.proceeding_types rows '
'(INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL) before mig 093 dropped '
'them. Source-of-truth for the down migration; persists post-drop '
'as the permanent audit record of the Pipeline-A proceeding '
'inventory. Drop with a focused follow-up after the Phase 3 cleanup '
'is verified in prod.';
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_093 AS
SELECT dr.*, now() AS snapshotted_at
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.category = 'litigation';
COMMENT ON TABLE paliad.deadline_rules_pre_093 IS
'Snapshot of the 40 paliad.deadline_rules rows that pointed at '
'litigation-category proceeding_types before mig 093 re-homed '
'them under the _archived_litigation pt. Source-of-truth for the '
'down migration; persists post-drop as the permanent audit record '
'of the Pipeline-A rule corpus.';
-- =============================================================================
-- 2. Create the holding proceeding_type `_archived_litigation`. Category
-- is the new 'archived' bucket (non-fristenrechner, so it cannot be
-- selected from any UI that filters category='fristenrechner', and
-- the mig 088 trigger continues to reject project-binding to it).
-- is_active=false so it doesn't appear in admin lists.
--
-- sort_order = 9999 to sit at the tail of any category sort. The
-- INSERT is idempotent via ON CONFLICT (code) DO NOTHING.
-- =============================================================================
INSERT INTO paliad.proceeding_types
(code, name, name_en, description, jurisdiction, category,
default_color, sort_order, display_order, is_active)
VALUES
('_archived_litigation',
'Archivierte Litigation-Regeln (Pipeline A)',
'Archived litigation rules (Pipeline A)',
'Holding proceeding_type for the 40 Pipeline-A litigation-category '
'rules retired by mig 093 (t-paliad-200, Slice 9 follow-up B). Not '
'selectable from any UI; preserves the rules + their 30 intra-set '
'parent_id chains for audit, and keeps the FK valid for the one '
'live deadline that still references inf.rejoin/INF.',
'UPC',
'archived',
'#94a3b8',
9999,
9999,
false)
ON CONFLICT (code) DO NOTHING;
-- =============================================================================
-- 3. Re-home all 40 rules to the archive pt and mark them archived.
-- The mig 079 trigger requires a non-empty audit_reason for UPDATE;
-- set_config above provides it. lifecycle_state='archived' +
-- is_active=false means projection_service / fristenrechner /
-- rule_editor filter them out by default. The intra-set parent_id
-- chains (30 of them) are preserved verbatim — parent_id values
-- point at the rule UUIDs which don't change.
--
-- Guard the UPDATE on lifecycle_state <> 'archived' so a second
-- application of the migration is a no-op (the rules are already
-- archived on the second run).
-- =============================================================================
UPDATE paliad.deadline_rules dr
SET proceeding_type_id = (SELECT id FROM paliad.proceeding_types
WHERE code = '_archived_litigation'),
lifecycle_state = 'archived',
is_active = false,
updated_at = now()
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.category = 'litigation'
AND dr.lifecycle_state <> 'archived';
-- =============================================================================
-- 4. Drop the 7 litigation rows from paliad.proceeding_types. Nothing
-- references them now: step 3 moved all 40 rules off; mig 087 moved
-- every project off; the audit confirmed zero cross-category spawn /
-- parent references. The FK is ON DELETE CASCADE but cascades zero
-- rows at this point.
-- =============================================================================
DELETE FROM paliad.proceeding_types
WHERE category = 'litigation';
-- =============================================================================
-- 5. Hard assertions. Raise loudly if anything didn't land — this
-- migration is not safe to leave half-applied because the litigation
-- pt rows are gone and the rule corpus needs to be coherent.
-- =============================================================================
DO $$
DECLARE
v_orphan_rules integer;
v_lit_rows integer;
v_archived integer;
v_archive_id integer;
BEGIN
SELECT id INTO v_archive_id
FROM paliad.proceeding_types
WHERE code = '_archived_litigation';
IF v_archive_id IS NULL THEN
RAISE EXCEPTION
'mig 093: _archived_litigation proceeding_type missing after step 2';
END IF;
-- No deadline_rules row still points at a litigation pt id (the
-- pt rows themselves are gone, so the proper check is "no rule
-- points at a row outside the surviving proceeding_types set").
-- This collapses to: no rule has a NULL proceeding_type from the
-- DELETE (the FK on rules → pt(id) is ON DELETE CASCADE; if we
-- missed a rule it would have been cascade-deleted in step 4).
-- Cross-check by counting rules that used to be on litigation pts:
SELECT count(*) INTO v_lit_rows
FROM paliad.proceeding_types
WHERE category = 'litigation';
IF v_lit_rows <> 0 THEN
RAISE EXCEPTION
'mig 093: % litigation proceeding_types rows survived the DELETE',
v_lit_rows;
END IF;
SELECT count(*) INTO v_archived
FROM paliad.deadline_rules
WHERE proceeding_type_id = v_archive_id;
IF v_archived <> 40 THEN
RAISE EXCEPTION
'mig 093: expected 40 rules on _archived_litigation, got %',
v_archived;
END IF;
-- Belt-and-braces: every snapshot row matches a surviving rule on
-- the archive pt by id. If any rule was cascade-deleted by a
-- missed step, this raises.
SELECT count(*) INTO v_orphan_rules
FROM paliad.deadline_rules_pre_093 snap
LEFT JOIN paliad.deadline_rules dr ON dr.id = snap.id
WHERE dr.id IS NULL;
IF v_orphan_rules <> 0 THEN
RAISE EXCEPTION
'mig 093: % rules from the pre-snapshot are missing from '
'paliad.deadline_rules — cascade-delete leak',
v_orphan_rules;
END IF;
END $$;

View File

@@ -0,0 +1,32 @@
-- mig 094 DOWN — restore the 7-digit CHECK and the snapshotted
-- pre-clear client_number / matter_number values from
-- paliad.projects_pre_094. Symmetric to the up migration.
SELECT set_config(
'paliad.audit_reason',
'mig 094 DOWN: restore 7-digit CHECK and pre-094 client_number/matter_number values from snapshot',
true);
-- 1. Drop the 6-digit CHECKs.
ALTER TABLE paliad.projects
DROP CONSTRAINT projekte_client_number_check,
DROP CONSTRAINT projekte_matter_number_check;
-- 2. Restore the original values from the snapshot. Only rows that
-- existed at snapshot time are touched; rows added since stay as
-- they were.
UPDATE paliad.projects p
SET client_number = s.client_number,
matter_number = s.matter_number
FROM paliad.projects_pre_094 s
WHERE p.id = s.id;
-- 3. Re-add the legacy 7-digit CHECKs.
ALTER TABLE paliad.projects
ADD CONSTRAINT projekte_client_number_check
CHECK (client_number IS NULL OR client_number ~ '^[0-9]{7}$'),
ADD CONSTRAINT projekte_matter_number_check
CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{7}$');
-- 4. Drop the snapshot. The down migration is the only consumer.
DROP TABLE IF EXISTS paliad.projects_pre_094;

View File

@@ -0,0 +1,97 @@
-- mig 094 — tighten paliad.projects.client_number + matter_number CHECK
-- from 7-digit to 6-digit. The "7-Ziffern" rule in mig 018 was wrong;
-- HLC's real Client/Matter format is 6 digits each (m's correction,
-- 2026-05-17). The constraints carry the legacy 'projekte_*_check'
-- name from before the table was renamed (mig 021), so the ALTER
-- TABLE DROP / ADD has to use those names verbatim.
--
-- Existing rows: only test data (2 client_numbers, 1 matter_number),
-- all 7-digit. They violate the new pattern, so we NULL them out
-- before tightening — preserving the project rows themselves, just
-- clearing the wrong-shaped billing identifiers. The rows are
-- snapshotted in projects_pre_094 first so the down migration can
-- restore them byte-identically.
--
-- audit_reason wrapper at top: the trigger on paliad.projects logs
-- every row-level UPDATE; the message persists in the audit table as
-- the permanent record of why those test values were cleared.
SELECT set_config(
'paliad.audit_reason',
'mig 094: clear test 7-digit client_number/matter_number values before tightening CHECK to 6-digit (HLC real format correction, 2026-05-17)',
true);
-- =============================================================================
-- 1. Backup snapshot. Full row copy of every paliad.projects row that
-- has either field populated. Idempotent via CREATE TABLE IF NOT
-- EXISTS — re-running the migration after an aborted run re-uses
-- the existing snapshot.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.projects_pre_094 AS
SELECT *, now() AS snapshotted_at
FROM paliad.projects
WHERE client_number IS NOT NULL OR matter_number IS NOT NULL;
COMMENT ON TABLE paliad.projects_pre_094 IS
'Snapshot of paliad.projects rows that had a client_number or '
'matter_number set before mig 094 tightened the CHECK from '
'7-digit to 6-digit. The 094 UPDATE NULL-ed those values out '
'because they were leftover 7-digit test data. Persists as the '
'permanent audit anchor; the down migration restores from it.';
-- =============================================================================
-- 2. Clear the 7-digit test values. Only rows that already violate
-- the new pattern are touched — anything that happens to already
-- be 6 digits (none today, but the WHERE keeps the migration
-- re-runnable after future inserts) is left alone.
-- =============================================================================
UPDATE paliad.projects
SET client_number = NULL
WHERE client_number IS NOT NULL
AND client_number !~ '^[0-9]{6}$';
UPDATE paliad.projects
SET matter_number = NULL
WHERE matter_number IS NOT NULL
AND matter_number !~ '^[0-9]{6}$';
-- =============================================================================
-- 3. Replace the legacy 7-digit CHECKs with 6-digit ones. The
-- constraint names carry the pre-rename `projekte_*` prefix from
-- mig 018; keep them stable so external audit tools that scan
-- pg_constraint by name don't drift.
-- =============================================================================
ALTER TABLE paliad.projects
DROP CONSTRAINT projekte_client_number_check,
DROP CONSTRAINT projekte_matter_number_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projekte_client_number_check
CHECK (client_number IS NULL OR client_number ~ '^[0-9]{6}$'),
ADD CONSTRAINT projekte_matter_number_check
CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{6}$');
-- =============================================================================
-- 4. Hard assertions. Any row that survived the UPDATE+ALTER must
-- satisfy the new pattern; the count of cleared test rows must
-- match the snapshot.
-- =============================================================================
DO $$
DECLARE
n_violations int;
BEGIN
SELECT count(*) INTO n_violations
FROM paliad.projects
WHERE (client_number IS NOT NULL AND client_number !~ '^[0-9]{6}$')
OR (matter_number IS NOT NULL AND matter_number !~ '^[0-9]{6}$');
IF n_violations > 0 THEN
RAISE EXCEPTION 'mig 094: % rows still violate the 6-digit pattern after UPDATE — should be 0', n_violations;
END IF;
RAISE NOTICE 'mig 094: 6-digit CHECKs in place, all rows compliant';
END $$;

View File

@@ -0,0 +1,61 @@
-- Reverses mig 095. Restores the 4 patched de_inf.* rows from
-- paliad.deadline_rules_pre_095 and removes the 4 new rules
-- (inf.prelim, rev.prelim, inf.appeal_spawn, rev.appeal_spawn).
--
-- The audit_reason is required by the mig 079 trigger for UPDATE +
-- DELETE; set_config at top supplies it.
SELECT set_config(
'paliad.audit_reason',
'mig 095 (down): revert t-paliad-205 fristen gap-fill — restore de_inf.* patches from deadline_rules_pre_095, delete 4 new rules',
true);
-- =============================================================================
-- 1. Delete the 4 new rules. Idempotent — if a rule is already missing
-- the DELETE matches zero rows.
-- =============================================================================
DELETE FROM paliad.deadline_rules
WHERE code IN ('inf.prelim', 'rev.prelim',
'inf.appeal_spawn', 'rev.appeal_spawn')
AND lifecycle_state = 'published';
-- =============================================================================
-- 2. Restore the 4 patched rows from the pre_095 snapshot. The snapshot
-- captured the rows at first up-migration run; the restore copies
-- each tracked field back. If the snapshot table doesn't exist (down
-- run before up), the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_095'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 095 (down): snapshot table paliad.deadline_rules_pre_095 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET legal_source = snap.legal_source,
is_court_set = snap.is_court_set,
description = snap.description,
updated_at = now()
FROM paliad.deadline_rules_pre_095 snap
WHERE dr.id = snap.id;
END $$;
-- =============================================================================
-- 3. Drop the snapshot table so a re-applied up migration captures a
-- fresh snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_095;

View File

@@ -0,0 +1,403 @@
-- t-paliad-205 / Fristen gap-fill — ingest curie's t-paliad-203 deltas
-- as code. Source of truth for the deltas is
-- docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3 (m's decisions
-- captured 2026-05-18, commit 0123d11).
--
-- Mig 093 (commit 40e49e8) retired the Pipeline-A litigation rule
-- corpus and surfaced four open coverage questions for legal review.
-- curie's proposal verified those questions and m signed off on:
--
-- * 4 new rules — preliminary-objection (RoP 19.1) on UPC_INF and
-- UPC_REV, and merits-appeal spawn (RoP 220.1(a)) on the same two
-- proceedings.
-- * 4 polish PATCHes on the German civil-procedure rules — backfill
-- legal_source on de_inf.klage, flip de_inf.erwidg to court-set
-- with a §276 Abs.1 S.2 note, plus a defensive verify on
-- de_inf.berufung.legal_source.
--
-- Final shape per the proposal § 0.3:
--
-- NEW
-- inf.prelim UPC_INF parent=inf.soc 1mo RoP.019.1 flag=with_po optional
-- rev.prelim UPC_REV parent=rev.app 1mo RoP.019.1 flag=with_po optional
-- inf.appeal_spawn UPC_INF parent=inf.decision 2mo RoP.220.1.a (no flag, always) optional spawn → UPC_APP (id=11)
-- rev.appeal_spawn UPC_REV parent=rev.decision 2mo RoP.220.1.a (no flag, always) optional spawn → UPC_APP (id=11)
--
-- PATCH
-- de_inf.klage legal_source NULL → 'DE.ZPO.253'
-- de_inf.anzeige no change (already 'DE.ZPO.276.1')
-- de_inf.erwidg is_court_set false → true; set description with §276 Abs.1 S.2 note
-- duration_value=6 weeks stays as the default-display value when no
-- court order is yet attached.
-- de_inf.berufung legal_source set to 'DE.ZPO.517' if still NULL (defensive verify)
--
-- The merits-appeal spawn rules unconditionally produce the 2-month
-- appeal-window row once inf.decision / rev.decision is anchored
-- (m's F2.3 decision: "appeal is always a possibility"). Visibility
-- filtering for non-appealing projects is a frontend concern, not a
-- rule-level flag (see proposal § 0.3 follow-up note).
--
-- The spawn_proceeding_type_id FK points at UPC_APP (id=11). t-paliad-204
-- may rename the `code` string for that row but the integer id is stable;
-- if id=11 ever moves, this migration's spawn rules still chain to the
-- correct row.
--
-- Idempotency:
-- * Backup snapshot `deadline_rules_pre_095` is CREATE TABLE IF NOT
-- EXISTS, capturing the 4 patched rows at first run.
-- * INSERTs use `WHERE NOT EXISTS` keyed on (proceeding_type_id, code,
-- lifecycle_state='published') — there is no unique index on
-- (proceeding_type_id, code) in paliad.deadline_rules (mig 093 left
-- archived and published rows co-existing with identical codes), so
-- ON CONFLICT is not available; WHERE NOT EXISTS is the equivalent
-- idempotency guard.
-- * UPDATEs are guarded by clauses that only fire when the row still
-- has the old value (legal_source IS NULL, is_court_set = false).
--
-- audit_reason wrapper required by the mig 079 trigger for both UPDATE
-- and INSERT (INSERT defaults to 'create' but we surface the t-paliad-205
-- context anyway so deadline_rule_audit reads cleanly).
SELECT set_config(
'paliad.audit_reason',
'mig 095: t-paliad-205 fristen gap-fill — 4 new rules (inf.prelim, rev.prelim, inf.appeal_spawn, rev.appeal_spawn) + 4 patches on de_inf.* rules per docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3',
true);
-- =============================================================================
-- 1. Backup snapshot of the 4 rows the PATCHes touch. CREATE TABLE IF
-- NOT EXISTS keeps this idempotent across reapplications. Snapshot
-- persists post-patch as the audit anchor; the down migration
-- restores from it.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_095 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules
WHERE code IN ('de_inf.klage', 'de_inf.anzeige',
'de_inf.erwidg', 'de_inf.berufung')
AND lifecycle_state = 'published'
AND is_active = true;
COMMENT ON TABLE paliad.deadline_rules_pre_095 IS
'Snapshot of the 4 de_inf.* deadline_rules rows that mig 095 '
'PATCHed (t-paliad-205). Source-of-truth for the down migration; '
'persists post-patch as the permanent audit record. Drop with a '
'focused follow-up after the gap-fill is verified in prod.';
-- =============================================================================
-- 2. New rules — preliminary objection on UPC_INF and UPC_REV
-- (RoP 19.1, flag-gated `with_po`, 1 month from service of the
-- Statement of Claim / Application for Revocation).
--
-- Anchor: parent_id on the existing root rule (inf.soc / rev.app),
-- matching the chaining pattern used by inf.sod, inf.def_to_ccr,
-- rev.defence, rev.app_to_amend. Idempotent via WHERE NOT EXISTS.
--
-- sequence_order=5 places the PO row before the SoD (sequence_order=10)
-- in the per-proceeding timeline ordering, reflecting the 1-month
-- statutory window beating the 3-month defence in calendar terms.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
8,
(SELECT id FROM paliad.deadline_rules
WHERE code = 'inf.soc'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND is_active = true),
'inf.prelim',
'Vorab-Einrede (R. 19 VerfO)',
'Preliminary Objection (RoP 19)',
'Vorab-Einrede des Beklagten gegen Zuständigkeit, Verfahrenssprache (R.14) oder Spruchkörper-Zusammensetzung. Statutarische Frist von 1 Monat ab Zustellung der Klage; der UPC entscheidet typischerweise durch Beschluss vor der Zwischenverhandlung (R.19.7).',
'defendant',
'filing',
1,
'months',
'after',
'RoP.019.1',
'Innerhalb von 1 Monat ab Zustellung der Klage. Drei mögliche Gründe: (a) Zuständigkeit/Kompetenz, (b) Verfahrenssprache (R.14), (c) Spruchkörper.',
'Within 1 month of service of the Statement of claim. Three available grounds: (a) jurisdiction/competence, (b) language (R.14), (c) panel composition.',
5,
false,
NULL,
NULL,
true,
'UPC.RoP.19.1',
false,
'{"flag":"with_po"}'::jsonb,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE code = 'inf.prelim'
AND proceeding_type_id = 8
AND lifecycle_state = 'published');
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
9,
(SELECT id FROM paliad.deadline_rules
WHERE code = 'rev.app'
AND proceeding_type_id = 9
AND lifecycle_state = 'published'
AND is_active = true),
'rev.prelim',
'Vorab-Einrede (R. 19 i.V.m. R. 46 VerfO)',
'Preliminary Objection (RoP 19 in conjunction with RoP 46)',
'Vorab-Einrede des Beklagten (Patentinhaber) im Nichtigkeitsverfahren. R.46 erklärt R.19 für Nichtigkeitsverfahren mutatis mutandis anwendbar; statutarische Frist von 1 Monat ab Zustellung der Nichtigkeitsklage.',
'defendant',
'filing',
1,
'months',
'after',
'RoP.019.1',
'Innerhalb von 1 Monat ab Zustellung der Nichtigkeitsklage. R.46 macht R.19 mutatis mutandis für Nichtigkeitsverfahren anwendbar; in der Praxis vor allem Verfahrenssprache und Spruchkörper-Zusammensetzung als Gründe.',
'Within 1 month of service of the Application for Revocation. R.46 makes R.19 apply mutatis mutandis to revocation actions; in practice the main grounds are language and panel composition.',
5,
false,
NULL,
NULL,
true,
'UPC.RoP.19.1',
false,
'{"flag":"with_po"}'::jsonb,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE code = 'rev.prelim'
AND proceeding_type_id = 9
AND lifecycle_state = 'published');
-- =============================================================================
-- 3. New rules — merits-appeal spawn on UPC_INF and UPC_REV
-- (RoP 220.1(a), 2 months from service of the final decision, always
-- fires once the decision is anchored). spawn_proceeding_type_id=11
-- is UPC_APP; the spawn renders as an entry point into the appeal
-- proceeding which already has app.notice / app.grounds as root
-- rules.
--
-- No condition_expr — m's F2.3 decision: "the appeal deadline should
-- always be triggered by a decision … appeal is always a possibility".
-- Visibility filtering on the frontend is the right place to hide
-- appeals on projects where no appeal is contemplated.
--
-- sequence_order=80 places the spawn row after inf.cost_app (70)
-- and rev.decision's tail in the per-proceeding ordering.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
8,
(SELECT id FROM paliad.deadline_rules
WHERE code = 'inf.decision'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND is_active = true),
'inf.appeal_spawn',
'Berufung gegen Endentscheidung',
'Appeal against final decision',
'Berufung gegen die Endentscheidung nach R.118. Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der Endentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
'Within 2 months of service of the final decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
80,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE code = 'inf.appeal_spawn'
AND proceeding_type_id = 8
AND lifecycle_state = 'published');
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
9,
(SELECT id FROM paliad.deadline_rules
WHERE code = 'rev.decision'
AND proceeding_type_id = 9
AND lifecycle_state = 'published'
AND is_active = true),
'rev.appeal_spawn',
'Berufung gegen Endentscheidung (Nichtigkeit)',
'Appeal against final decision (revocation)',
'Berufung gegen die Endentscheidung im Nichtigkeitsverfahren nach R.118. Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)). Bei with_cci-Konstellationen (Verletzungswiderklage) deckt eine R.118-Entscheidung beide Streitgegenstände ab und erzeugt ein gemeinsames Berufungsfenster.',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der Endentscheidung Berufungsschrift einreichen (R.224.1(a)). Bei Verletzungswiderklage (with_cci) ein gemeinsames Fenster.',
'Within 2 months of service of the final decision lodge the Statement of appeal (R.224.1(a)). Where a counterclaim for infringement was raised (with_cci) the appeal window covers both parts.',
80,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE code = 'rev.appeal_spawn'
AND proceeding_type_id = 9
AND lifecycle_state = 'published');
-- =============================================================================
-- 4. PATCHes on existing rows. Each UPDATE is guarded by a WHERE clause
-- that only fires when the row still has the old value — re-running
-- the migration is a no-op once the first run has applied.
-- =============================================================================
-- 4.1 de_inf.klage: legal_source NULL → 'DE.ZPO.253'
UPDATE paliad.deadline_rules
SET legal_source = 'DE.ZPO.253',
updated_at = now()
WHERE code = 'de_inf.klage'
AND lifecycle_state = 'published'
AND is_active = true
AND legal_source IS NULL;
-- 4.2 de_inf.anzeige: no change — verified DE.ZPO.276.1 already correct
-- (proposal § 4.2; intentional no-op to make the audit log complete).
-- 4.3 de_inf.erwidg: flip is_court_set true; set description with §276
-- Abs.1 S.2 note. Keep duration_value=6, duration_unit='weeks' as
-- the default-display value when no court order is yet attached
-- (per § 0.3 — fristenrechner renders the 6-week heuristic until
-- the user enters the actual court-set date).
UPDATE paliad.deadline_rules
SET is_court_set = true,
description = 'Gericht setzt eine Frist von mindestens zwei Wochen ab Verteidigungsanzeige (§276 Abs. 1 S. 2 ZPO).',
updated_at = now()
WHERE code = 'de_inf.erwidg'
AND lifecycle_state = 'published'
AND is_active = true
AND is_court_set = false;
-- 4.4 de_inf.berufung: defensive verify — set legal_source to
-- 'DE.ZPO.517' only if currently NULL. Production value is already
-- 'DE.ZPO.517' per the proposal § 4.4 verification, so this is a
-- no-op in prod; preserved here as a belt-and-braces guard against
-- a staging snapshot where the field was never backfilled.
UPDATE paliad.deadline_rules
SET legal_source = 'DE.ZPO.517',
updated_at = now()
WHERE code = 'de_inf.berufung'
AND lifecycle_state = 'published'
AND is_active = true
AND legal_source IS NULL;
-- =============================================================================
-- 5. Hard assertions. The migration is not safe to leave half-applied —
-- if any of the new rules failed to insert, or the de_inf.erwidg
-- flip didn't land, the fristenrechner corpus is inconsistent.
-- =============================================================================
DO $$
DECLARE
v_new_rules integer;
v_court_set integer;
v_appeal_ids integer;
v_klage_src text;
BEGIN
-- 5.1 All four new rules exist and are active+published
SELECT count(*) INTO v_new_rules
FROM paliad.deadline_rules
WHERE code IN ('inf.prelim', 'rev.prelim',
'inf.appeal_spawn', 'rev.appeal_spawn')
AND is_active = true
AND lifecycle_state = 'published';
IF v_new_rules <> 4 THEN
RAISE EXCEPTION
'mig 095: expected 4 new active+published rules, got %',
v_new_rules;
END IF;
-- 5.2 de_inf.erwidg is now court-set
SELECT count(*) INTO v_court_set
FROM paliad.deadline_rules
WHERE code = 'de_inf.erwidg'
AND lifecycle_state = 'published'
AND is_active = true
AND is_court_set = true;
IF v_court_set <> 1 THEN
RAISE EXCEPTION
'mig 095: expected de_inf.erwidg to be court-set after patch, got % matching rows',
v_court_set;
END IF;
-- 5.3 Both spawn rules reference an existing proceeding_type id=11
SELECT count(*) INTO v_appeal_ids
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.spawn_proceeding_type_id
WHERE dr.code IN ('inf.appeal_spawn', 'rev.appeal_spawn')
AND dr.lifecycle_state = 'published'
AND dr.is_active = true
AND pt.id = 11;
IF v_appeal_ids <> 2 THEN
RAISE EXCEPTION
'mig 095: expected both appeal_spawn rules to chain to proceeding_type id=11, got % matching rows',
v_appeal_ids;
END IF;
-- 5.4 de_inf.klage now has a legal_source (we just set it, or it was
-- already set — either way it must not be NULL after this mig)
SELECT legal_source INTO v_klage_src
FROM paliad.deadline_rules
WHERE code = 'de_inf.klage'
AND lifecycle_state = 'published'
AND is_active = true;
IF v_klage_src IS NULL THEN
RAISE EXCEPTION
'mig 095: de_inf.klage.legal_source is still NULL after patch';
END IF;
END $$;

View File

@@ -0,0 +1,99 @@
-- Reverses mig 096. Restores the original UPPER_SNAKE codes on
-- paliad.proceeding_types + paliad.event_category_concepts, drops the
-- new upc.ccr.cfi row, removes the shape CHECK, refreshes the
-- deadline_search materialized view, then drops the snapshot table.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 096 (down): revert t-paliad-206 proceeding-code rename — restore UPPER_SNAKE codes from proceeding_types_pre_096, delete upc.ccr.cfi peer, drop shape CHECK',
true);
-- =============================================================================
-- 1. Drop the shape CHECK first so the UPPER_SNAKE restores don't trip it.
-- =============================================================================
ALTER TABLE paliad.proceeding_types
DROP CONSTRAINT IF EXISTS paliad_proceeding_code_shape;
-- =============================================================================
-- 2. Delete the upc.ccr.cfi peer. The down restores the pre-096 state, which
-- didn't have this row. If the row is already missing, the DELETE
-- matches zero — idempotent.
-- =============================================================================
DELETE FROM paliad.proceeding_types
WHERE code = 'upc.ccr.cfi';
-- =============================================================================
-- 3. Restore proceeding_types.code from the pre_096 snapshot. The snapshot
-- captured the rows at first up-migration run; if the table is missing
-- (down run before up), the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'proceeding_types_pre_096'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 096 (down): snapshot table paliad.proceeding_types_pre_096 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.proceeding_types pt
SET code = snap.code
FROM paliad.proceeding_types_pre_096 snap
WHERE pt.id = snap.id
AND pt.code <> snap.code;
END $$;
-- =============================================================================
-- 4. Revert soft references on event_category_concepts.proceeding_type_code
-- by running the inverse mapping. Symmetric with §4 of the up migration.
-- =============================================================================
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_INF' WHERE proceeding_type_code = 'upc.inf.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_REV' WHERE proceeding_type_code = 'upc.rev.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_PI' WHERE proceeding_type_code = 'upc.pi.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_APP' WHERE proceeding_type_code = 'upc.apl.merits';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_DAMAGES' WHERE proceeding_type_code = 'upc.dmgs.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_DISCOVERY' WHERE proceeding_type_code = 'upc.disc.cfi';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_COST_APPEAL' WHERE proceeding_type_code = 'upc.apl.cost';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'UPC_APP_ORDERS' WHERE proceeding_type_code = 'upc.apl.order';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_INF' WHERE proceeding_type_code = 'de.inf.lg';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_INF_OLG' WHERE proceeding_type_code = 'de.inf.olg';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_INF_BGH' WHERE proceeding_type_code = 'de.inf.bgh';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_NULL' WHERE proceeding_type_code = 'de.null.bpatg';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DE_NULL_BGH' WHERE proceeding_type_code = 'de.null.bgh';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'EP_GRANT' WHERE proceeding_type_code = 'epa.grant.exa';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'EPA_OPP' WHERE proceeding_type_code = 'epa.opp.opd';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'EPA_APP' WHERE proceeding_type_code = 'epa.opp.boa';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DPMA_OPP' WHERE proceeding_type_code = 'dpma.opp.dpma';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DPMA_BPATG_BESCHWERDE' WHERE proceeding_type_code = 'dpma.appeal.bpatg';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'DPMA_BGH_RB' WHERE proceeding_type_code = 'dpma.appeal.bgh';
-- =============================================================================
-- 5. Refresh deadline_search so the reverted proceeding_code strings
-- repopulate the materialized view.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 6. Drop the snapshot table so a re-applied up migration captures a
-- fresh snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.proceeding_types_pre_096;

View File

@@ -0,0 +1,226 @@
-- t-paliad-206 / proceeding-code rename — replace the historical
-- UPPER_SNAKE proceeding codes with the lowercase dot-separated
-- taxonomy ratified by m on 2026-05-18 (see
-- docs/design-proceeding-code-taxonomy-2026-05-18.md).
--
-- IDs are stable. Only the `code` STRING changes. FKs
-- (deadline_rules.proceeding_type_id, projects.proceeding_type_id,
-- deadline_rules.spawn_proceeding_type_id) reference IDs, so the
-- existing rule corpus and spawn wiring continue to work unchanged
-- (incl. mig 095's spawn_proceeding_type_id=11 which becomes
-- 'upc.apl.merits' after this migration).
--
-- Soft references on `code` (text column on event_category_concepts) are
-- updated row-for-row to keep the soft join through proceeding_types.code
-- resolving.
--
-- The materialized view paliad.deadline_search projects pt.code as
-- proceeding_code; mig 096 REFRESHes it at the bottom so the new codes
-- show up in search results immediately.
--
-- Idempotent:
-- * UPDATEs are guarded by `WHERE code = '<OLD>'`. Re-running after a
-- successful first apply is a no-op.
-- * INSERT of upc.ccr.cfi uses `WHERE NOT EXISTS` keyed on the new
-- code (bohr noted in t-paliad-205 that a UNIQUE constraint on the
-- code column is not present, hence WHERE NOT EXISTS rather than
-- ON CONFLICT).
-- * CHECK constraint is dropped-then-recreated under the same name
-- (paliad_proceeding_code_shape) so reapplication doesn't error.
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 096: t-paliad-206 proceeding-code rename — lowercase dot-separated taxonomy + new upc.ccr.cfi illustrative peer; see docs/design-proceeding-code-taxonomy-2026-05-18.md',
true);
-- =============================================================================
-- 1. Backup snapshot of paliad.proceeding_types BEFORE the rename. The
-- rename is forward-only in code (the Go + frontend sweeps reference
-- the new strings) but the DB snapshot is the audit anchor and the
-- source for the down migration.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.proceeding_types_pre_096 AS
SELECT *, now() AS snapshotted_at
FROM paliad.proceeding_types;
COMMENT ON TABLE paliad.proceeding_types_pre_096 IS
'Snapshot of paliad.proceeding_types taken before mig 096 renamed '
'the `code` strings to the lowercase dot-separated taxonomy '
'(t-paliad-206, 2026-05-18). Source-of-truth for the down '
'migration; persists post-rename as the permanent audit record.';
-- =============================================================================
-- 2. Drop any prior shape CHECK so we can recreate it post-rename. The
-- constraint name is stable so reapplication idempotently drops it.
-- =============================================================================
ALTER TABLE paliad.proceeding_types
DROP CONSTRAINT IF EXISTS paliad_proceeding_code_shape;
-- =============================================================================
-- 3. The 19 renames. Order-independent — every UPDATE is guarded by
-- `WHERE code = '<OLD>'` so re-application is a no-op. id values in
-- the trailing comment for cross-reference with the design doc.
-- =============================================================================
-- UPC
UPDATE paliad.proceeding_types SET code = 'upc.inf.cfi' WHERE code = 'UPC_INF'; -- id=8
UPDATE paliad.proceeding_types SET code = 'upc.rev.cfi' WHERE code = 'UPC_REV'; -- id=9
UPDATE paliad.proceeding_types SET code = 'upc.pi.cfi' WHERE code = 'UPC_PI'; -- id=10
UPDATE paliad.proceeding_types SET code = 'upc.apl.merits' WHERE code = 'UPC_APP'; -- id=11
UPDATE paliad.proceeding_types SET code = 'upc.dmgs.cfi' WHERE code = 'UPC_DAMAGES'; -- id=17
UPDATE paliad.proceeding_types SET code = 'upc.disc.cfi' WHERE code = 'UPC_DISCOVERY'; -- id=18
UPDATE paliad.proceeding_types SET code = 'upc.apl.cost' WHERE code = 'UPC_COST_APPEAL';-- id=19
UPDATE paliad.proceeding_types SET code = 'upc.apl.order' WHERE code = 'UPC_APP_ORDERS'; -- id=20
-- DE
UPDATE paliad.proceeding_types SET code = 'de.inf.lg' WHERE code = 'DE_INF'; -- id=12
UPDATE paliad.proceeding_types SET code = 'de.inf.olg' WHERE code = 'DE_INF_OLG'; -- id=25
UPDATE paliad.proceeding_types SET code = 'de.inf.bgh' WHERE code = 'DE_INF_BGH'; -- id=26
UPDATE paliad.proceeding_types SET code = 'de.null.bpatg' WHERE code = 'DE_NULL'; -- id=13
UPDATE paliad.proceeding_types SET code = 'de.null.bgh' WHERE code = 'DE_NULL_BGH'; -- id=27
-- EPA
UPDATE paliad.proceeding_types SET code = 'epa.grant.exa' WHERE code = 'EP_GRANT'; -- id=16
UPDATE paliad.proceeding_types SET code = 'epa.opp.opd' WHERE code = 'EPA_OPP'; -- id=14
UPDATE paliad.proceeding_types SET code = 'epa.opp.boa' WHERE code = 'EPA_APP'; -- id=15
-- DPMA
UPDATE paliad.proceeding_types SET code = 'dpma.opp.dpma' WHERE code = 'DPMA_OPP'; -- id=28
UPDATE paliad.proceeding_types SET code = 'dpma.appeal.bpatg' WHERE code = 'DPMA_BPATG_BESCHWERDE';-- id=29
UPDATE paliad.proceeding_types SET code = 'dpma.appeal.bgh' WHERE code = 'DPMA_BGH_RB'; -- id=30
-- =============================================================================
-- 4. Update soft references on event_category_concepts.proceeding_type_code.
-- Same OLD→NEW table as above; the column has a UNIQUE NULLS NOT
-- DISTINCT constraint on (event_category_id, concept_id, proceeding_type_code)
-- but no row has the NEW string yet so the UPDATEs cannot collide.
-- =============================================================================
-- UPC
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.inf.cfi' WHERE proceeding_type_code = 'UPC_INF';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.rev.cfi' WHERE proceeding_type_code = 'UPC_REV';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.pi.cfi' WHERE proceeding_type_code = 'UPC_PI';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.apl.merits' WHERE proceeding_type_code = 'UPC_APP';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.dmgs.cfi' WHERE proceeding_type_code = 'UPC_DAMAGES';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.disc.cfi' WHERE proceeding_type_code = 'UPC_DISCOVERY';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.apl.cost' WHERE proceeding_type_code = 'UPC_COST_APPEAL';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'upc.apl.order' WHERE proceeding_type_code = 'UPC_APP_ORDERS';
-- DE
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.inf.lg' WHERE proceeding_type_code = 'DE_INF';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.inf.olg' WHERE proceeding_type_code = 'DE_INF_OLG';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.inf.bgh' WHERE proceeding_type_code = 'DE_INF_BGH';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.null.bpatg' WHERE proceeding_type_code = 'DE_NULL';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'de.null.bgh' WHERE proceeding_type_code = 'DE_NULL_BGH';
-- EPA
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'epa.grant.exa' WHERE proceeding_type_code = 'EP_GRANT';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'epa.opp.opd' WHERE proceeding_type_code = 'EPA_OPP';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'epa.opp.boa' WHERE proceeding_type_code = 'EPA_APP';
-- DPMA
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'dpma.opp.dpma' WHERE proceeding_type_code = 'DPMA_OPP';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'dpma.appeal.bpatg' WHERE proceeding_type_code = 'DPMA_BPATG_BESCHWERDE';
UPDATE paliad.event_category_concepts SET proceeding_type_code = 'dpma.appeal.bgh' WHERE proceeding_type_code = 'DPMA_BGH_RB';
-- =============================================================================
-- 5. Insert the new illustrative peer `upc.ccr.cfi`. is_active=true so it
-- surfaces in the determinator + dropdowns; no rules attached.
-- proceeding_mapping.go routes cascade hits on this code back to
-- upc.inf.cfi (id=8) with the with_ccr default flag — see design doc S1.
--
-- WHERE NOT EXISTS gates the insert on the new code so re-application
-- is a no-op even though there's no UNIQUE constraint on (code).
-- =============================================================================
INSERT INTO paliad.proceeding_types
(code, category, jurisdiction, is_active, name, name_en, description)
SELECT
'upc.ccr.cfi',
'fristenrechner',
'UPC',
true,
'Widerklage auf Nichtigkeit',
'Counterclaim for Revocation',
'Illustrativer Peer von upc.inf.cfi für Widerklagen auf Nichtigkeit. Regeln liegen auf upc.inf.cfi (with_ccr=true); der Fristenrechner leitet bei Auswahl dorthin weiter. Keine eigenen Fristregeln.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.proceeding_types
WHERE code = 'upc.ccr.cfi');
-- =============================================================================
-- 6. CHECK constraint on the code shape. Active rows must conform to the
-- new lowercase dot-separated form; the carve-out for
-- `_archived_litigation` keeps the Pipeline-A bucket addressable.
-- =============================================================================
ALTER TABLE paliad.proceeding_types
ADD CONSTRAINT paliad_proceeding_code_shape
CHECK (
code ~ '^[a-z]+\.[a-z]+\.[a-z]+$'
OR code ~ '^_archived_'
);
-- =============================================================================
-- 7. Refresh the deadline_search materialized view so search hits return
-- the new proceeding_code strings immediately.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 8. Hard assertions. Half-applied migrations would leave the rule corpus
-- inconsistent with the new shape; assert every active fristenrechner
-- code conforms and that no old codes leak.
-- =============================================================================
DO $$
DECLARE
v_new_shape integer;
v_old_codes integer;
v_ccr_row integer;
BEGIN
-- 8.1 Every active fristenrechner row matches the new shape regex.
-- 20 = 19 renamed rows + 1 newly inserted upc.ccr.cfi. The check
-- uses >= so an additional row added in a follow-up migration
-- doesn't trip the assertion.
SELECT count(*) INTO v_new_shape
FROM paliad.proceeding_types
WHERE category = 'fristenrechner'
AND is_active = true
AND code ~ '^[a-z]+\.[a-z]+\.[a-z]+$';
IF v_new_shape < 20 THEN
RAISE EXCEPTION
'mig 096: expected >= 20 active fristenrechner rows on the new shape, got %',
v_new_shape;
END IF;
-- 8.2 No old UPPER_SNAKE codes remain on any row.
SELECT count(*) INTO v_old_codes
FROM paliad.proceeding_types
WHERE code LIKE 'UPC\_%' ESCAPE '\'
OR code LIKE 'DE\_%' ESCAPE '\'
OR code LIKE 'EPA\_%' ESCAPE '\'
OR code LIKE 'EP\_%' ESCAPE '\'
OR code LIKE 'DPMA\_%' ESCAPE '\';
IF v_old_codes <> 0 THEN
RAISE EXCEPTION
'mig 096: expected 0 old UPPER_SNAKE codes after rename, got %',
v_old_codes;
END IF;
-- 8.3 The new ccr peer exists and is active.
SELECT count(*) INTO v_ccr_row
FROM paliad.proceeding_types
WHERE code = 'upc.ccr.cfi'
AND is_active = true;
IF v_ccr_row <> 1 THEN
RAISE EXCEPTION
'mig 096: expected 1 active upc.ccr.cfi row, got %',
v_ccr_row;
END IF;
END $$;

View File

@@ -0,0 +1,59 @@
-- Reverses mig 097. Restores rule_code + legal_source on every row
-- touched by the backfill (and the rev.defence normalization) from the
-- paliad.deadline_rules_pre_097 snapshot, refreshes the deadline_search
-- materialized view, then drops the snapshot.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 097 (down): revert t-paliad-210 legal-citation backfill — restore rule_code/legal_source from deadline_rules_pre_097 snapshot',
true);
-- =============================================================================
-- 1. Restore rule_code + legal_source from the pre_097 snapshot for every
-- row whose current values diverge from the snapshot. Symmetric across
-- the § 1 / § 2 / § 3 backfills and the § 5 rev.defence normalization
-- in one pass. If the snapshot table is missing (down run before up),
-- the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_097'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 097 (down): snapshot table paliad.deadline_rules_pre_097 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET rule_code = snap.rule_code,
legal_source = snap.legal_source
FROM paliad.deadline_rules_pre_097 snap
WHERE dr.id = snap.id
AND (dr.rule_code IS DISTINCT FROM snap.rule_code
OR dr.legal_source IS DISTINCT FROM snap.legal_source);
END $$;
-- =============================================================================
-- 2. Refresh deadline_search so the reverted rule_code / legal_source
-- values repopulate the materialized view.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 3. Drop the snapshot so a re-applied up migration captures a fresh
-- snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_097;

View File

@@ -0,0 +1,684 @@
-- t-paliad-210 / legal-citation backfill — apply huygens's HIGH/MED
-- proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
-- (commit 391be09) PLUS m's 2026-05-18 FLAG walk-through (paliadin/head
-- instruction-msg 2002). Scope grew from the original brief: m approved
-- filling almost every category, with only 3 FLAG-J rows left NULL.
--
-- Touches (in 8 buckets, ~135 rows):
--
-- § 1 Easy wins — 6 rows. rule_code only. The 2
-- § 123 PatG twins (Wiedereinsetzung)
-- move into the FLAG-A dedup bucket
-- below; not filled here.
--
-- § 2 HIGH/MED proceeding-typed — 15 rows. rule_code + legal_source.
--
-- § 3 HIGH/MED orphans — 47 rows. rule_code + legal_source.
-- For UPC rows also rule_codes[]
-- normalized to ARRAY[rule_code].
-- Excludes 3 archive-dest dup rows
-- that are filled via the canonical
-- in § 4 instead (5c0508f4 /
-- 791fd0f7 / d886f46f).
--
-- § 4 FLAG-A dedup (clean only) — 3 canonical fills + 3 archive
-- flips. Only sets where the
-- duplicate rows share an existing
-- rule_codes[] value (or both are
-- NULL) are deduped:
-- * 2× "Wiedereinsetzungsantrag
-- § 123 PatG" — canonical
-- b588fa64 (lowest UUID),
-- archive c24d494c.
-- * 2× "Berufungsschrift R.220.1
-- (a)/(b)" — canonical 1dfba5b1
-- (filled in § 3.3), archive
-- 5c0508f4.
-- * 2× "Berufungsbegründung R.220.1
-- (a)/(b)" — canonical 573df3d1
-- (filled in § 3.3), archive
-- 791fd0f7.
--
-- DEFERRED (paliadin/head msg 2006,
-- pending m's call): 6× "Mängel-
-- beseitigung / Zahlung" and 2×
-- "Beginn des Hauptsacheverfahrens".
-- Each row in those sets carries a
-- DIFFERENT existing rule_codes[]
-- value (Mängelbeseitigung: RoP.207
-- .6.a, RoP.253.2, RoP.016.3.a,
-- RoP.027.2, RoP.089.2, RoP.229.2;
-- Beginn-Hauptsache: RoP.198 vs
-- RoP.213). These may be distinct
-- procedural-context rules masquer-
-- ading as duplicates; m owns the
-- collapse-or-preserve decision.
-- Mig 097 leaves all 8 rows
-- untouched (rule_code stays NULL,
-- rule_codes[] stays as-is, neither
-- archived nor filled).
--
-- § 5 FLAG-B court-scheduled — 26 rows. Per m: "try to find the
-- rules — they often exist." Cites
-- the framing norm authorising the
-- court to schedule the event (RoP.111
-- for UPC oral hearings, RoP.118 for
-- UPC decisions, § 285 ZPO / § 300
-- ZPO for DE Verhandlung / Urteil,
-- § 47 / 78 / 79 / 107 PatG for
-- DPMA/BPatG/BGH variants, etc.).
--
-- § 6 FLAG-C/D rubber-stamp — 5 rows. rev.reply/rev.rejoin/
-- app.response use canonical RoP.5x
-- regardless of duration-vs-norm
-- mismatch (m: "just go ahead").
-- de_inf.replik/de_inf.duplik cite
-- § 273 ZPO (court-set framing).
--
-- § 7 FLAG-E service triggers — 6 rows (DE/EPA). Service-trigger
-- citations on Zustellung events.
-- UPC initial-submission rows carry
-- the RoP.271.b 10-day deferral as a
-- secondary cite in rule_codes[]
-- (handled in § 9 below).
--
-- § 8 FLAG-F combined-pleading — 5 rows. Use rule_codes[] multi-cite
-- array (column already exists from
-- mig 095). Primary cite in
-- rule_code, full set in rule_codes[].
--
-- § 9 FLAG-G/H/I + RoP.271.b — 13 rows. G: 2 Patentänderung
-- orphans split by INF/REV context.
-- H: 8 sub-paragraph spot-checks
-- applied as-is per the doc. I: 3
-- negative-declaration rows cite
-- RoP.069 by analogy.
-- Plus: 5 UPC initial-submission rows
-- append RoP.271.b to rule_codes[]
-- as the 10-day service deferral.
-- m flagged this distinct from the
-- primary substantive cite.
--
-- § 10 R.19 label rename — 2 rows max. inf.prelim / rev.prelim:
-- set name to "Einspruch (R. 19 VerfO)"
-- / "Einspruch (R. 19 i.V.m. R. 46
-- VerfO)" + rule_code 'RoP.019.1'.
-- Originally drafted in fermi's
-- t-paliad-207 session; m applied the
-- rename live on prod and asked us to
-- consolidate the mig here per Path-A.
-- Guard `name LIKE 'Vorab-Einrede%'`
-- makes this a defensive no-op on the
-- prod DB (fermi already wrote there)
-- but applies cleanly on any future
-- deploy that hasn't seen the live
-- write.
--
-- § 11 Side-fix RoP.49.1 → .049.1 — 1 row. rev.defence carries an
-- un-padded rule_code; all other UPC
-- RoP rules under 100 use 3-digit
-- padding. legal_source stays
-- 'UPC.RoP.49.1' (structured locator
-- never pads).
--
-- FLAG-J kept NULL (3 rows: d124c95b — Aufhebung Entscheidung des
-- Amtes, 002c2ba7 — Folgemaßnahmen Validitätsentscheidung, 902cc5d5 —
-- Klärung Übersetzungsfragen). m will pick them up later via
-- /admin/rules. Existing rule_codes[] on these is left untouched.
--
-- Idempotent:
-- * Backfill UPDATEs guarded on `rule_code IS NULL` (the de-novo fill
-- bucket) — re-running is a no-op.
-- * Archive UPDATEs guarded on `is_active = true AND lifecycle_state
-- = 'published'` — re-running is a no-op.
-- * Normalization UPDATE guarded on `rule_code = 'RoP.49.1'` — no-op
-- after first apply.
-- * Prelim rename UPDATEs guarded on `name LIKE 'Vorab-Einrede%'` —
-- no-op after first apply or on prod (fermi already wrote).
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
-- * Materialized-view refresh is safe to repeat.
--
-- audit_reason is set at the top via set_config(..., true) so the
-- mig-079 audit trigger on paliad.deadline_rules accepts the UPDATEs.
SELECT set_config(
'paliad.audit_reason',
'mig 097: t-paliad-210 legal-citation backfill — m''s FLAG walk-through 2026-05-18 (paliadin/head msg 2002). HIGH/MED proposals from docs/proposals/legal-citation-backfill-2026-05-18.md (commit 391be09) plus FLAG-A dedup + FLAG-B court-scheduled cites + FLAG-F rule_codes[] multi-cite + RoP.271.b on UPC initial submissions + RoP.49.1 padding normalization + R.19 prelim rename (fermi/t-paliad-207 consolidated)',
true);
-- =============================================================================
-- 0. Backup snapshot of paliad.deadline_rules BEFORE the backfill. Full
-- table snapshot for the complete pre-097 baseline. Matches the
-- mig 096 pattern (proceeding_types_pre_096).
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_097 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_097 IS
'Snapshot of paliad.deadline_rules taken before mig 097 backfilled '
'rule_code + legal_source + rule_codes[] across huygens''s HIGH/MED '
'proposals (t-paliad-208) and m''s expanded FLAG walk-through '
'(2026-05-18). Source-of-truth for the down migration; persists '
'post-backfill as the permanent audit anchor — also retains the '
'pre-dedup per-row rule_codes[] for the Mängelbeseitigung × 6 + '
'Beginn-Hauptsache × 2 sets in case m later wants to recover the '
'procedural-context citations.';
-- =============================================================================
-- 1. § 1 Easy wins (6 rows). legal_source already populated; only
-- rule_code missing. The 2 § 123 PatG Wiedereinsetzung twins
-- (c24d494c…, b588fa64…) are handled in § 4 below as part of the
-- FLAG-A dedup.
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = '§ 253 ZPO'
WHERE id = '1f532c82-9e6d-4f48-bd16-fa2fc71d5880' AND rule_code IS NULL; -- de_inf.klage / Klageerhebung
UPDATE paliad.deadline_rules SET rule_code = '§ 339 ZPO'
WHERE id = '20254f4e-d213-4cf6-8f5f-1d9d36eeb6ac' AND rule_code IS NULL; -- Einspruch gegen Versäumnisurteil
UPDATE paliad.deadline_rules SET rule_code = '§ 296a ZPO'
WHERE id = '3c36f149-3a81-456e-aac1-d4d18bfcb16b' AND rule_code IS NULL; -- Schriftsatznachreichung
UPDATE paliad.deadline_rules SET rule_code = 'R. 135 EPÜ'
WHERE id = 'f1099cf6-4c87-430e-b1c5-488bd44cb143' AND rule_code IS NULL; -- Weiterbehandlungsantrag (Art. 121 EPÜ)
UPDATE paliad.deadline_rules SET rule_code = '§ 234 ZPO'
WHERE id = 'd40d9be7-e1b6-451c-bee2-6eaee2307ec5' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (§ 233 ZPO)
UPDATE paliad.deadline_rules SET rule_code = 'R. 136 EPÜ'
WHERE id = '23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6' AND rule_code IS NULL; -- Wiedereinsetzungsantrag (Art. 122 EPÜ)
-- =============================================================================
-- 2. § 2 Proceeding-typed HIGH/MED (15 rows). rule_code + legal_source.
-- Note: rule_codes[] is set in § 9 for the 5 UPC initial-submission
-- rows (inf.soc / rev.app / pi.app / damages.app / disc.app) to
-- include the RoP.271.b secondary cite. For DE/EPA rows here,
-- rule_codes[] is left untouched (currently NULL and not used for
-- DE/EPA citations in this corpus).
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = 'RoP.013.1', legal_source = 'UPC.RoP.13.1'
WHERE id = '42be6c9b-8e84-4804-962f-94c3315aca1b' AND rule_code IS NULL; -- upc.inf.cfi / inf.soc
UPDATE paliad.deadline_rules SET rule_code = 'RoP.042', legal_source = 'UPC.RoP.42'
WHERE id = '995c108e-e73a-4f9c-b79f-47abe7c94108' AND rule_code IS NULL; -- upc.rev.cfi / rev.app
UPDATE paliad.deadline_rules SET rule_code = 'RoP.206', legal_source = 'UPC.RoP.206'
WHERE id = 'ed0194b7-74ab-4402-8971-7211f6036ff9' AND rule_code IS NULL; -- upc.pi.cfi / pi.app
UPDATE paliad.deadline_rules SET rule_code = 'RoP.243', legal_source = 'UPC.RoP.243', rule_codes = ARRAY['RoP.243']::text[]
WHERE id = '85f92b72-c654-4429-8e91-03402f9438c6' AND rule_code IS NULL; -- upc.apl.merits / app.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.131', legal_source = 'UPC.RoP.131'
WHERE id = '3e1719e8-f6f6-4260-8f02-754bd214937f' AND rule_code IS NULL; -- upc.dmgs.cfi / damages.app
UPDATE paliad.deadline_rules SET rule_code = 'RoP.141', legal_source = 'UPC.RoP.141'
WHERE id = 'eb1fa1d1-b345-42ba-ab14-79f5284166b0' AND rule_code IS NULL; -- upc.disc.cfi / disc.app
UPDATE paliad.deadline_rules SET rule_code = '§ 81 PatG', legal_source = 'DE.PatG.81.1'
WHERE id = 'ba33e704-18f6-4486-8107-abdb1e9cbfad' AND rule_code IS NULL; -- de.null.bpatg / de_null.klage
UPDATE paliad.deadline_rules SET rule_code = '§ 58 PatG', legal_source = 'DE.PatG.58.1'
WHERE id = '972f8fe4-8f4c-4497-9736-d60399ae5989' AND rule_code IS NULL; -- dpma.opp.dpma / dpma_opp.publish
UPDATE paliad.deadline_rules SET rule_code = 'Art. 75 EPÜ', legal_source = 'EU.EPÜ.75'
WHERE id = 'a1766364-1478-4b13-ae02-0a94367c585e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.filing
UPDATE paliad.deadline_rules SET rule_code = 'Art. 92 EPÜ', legal_source = 'EU.EPÜ.92'
WHERE id = '63069ae5-e380-4db5-b020-d1856f31300c' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.search
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.1'
WHERE id = '86b3a295-d76b-4566-955d-55f7a394524e' AND rule_code IS NULL; -- epa.grant.exa / ep_grant.grant
UPDATE paliad.deadline_rules SET rule_code = 'Art. 97 EPÜ', legal_source = 'EU.EPÜ.97.3'
WHERE id = '520dd205-7b4a-45f4-b87f-e2be5d1e183e' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.grant
UPDATE paliad.deadline_rules SET rule_code = 'Art. 101 EPÜ', legal_source = 'EU.EPÜ.101'
WHERE id = '8961a54b-2645-4af4-b0f5-114128150839' AND rule_code IS NULL; -- epa.opp.opd / epa_opp.entsch
UPDATE paliad.deadline_rules SET rule_code = 'Art. 116 EPÜ', legal_source = 'EU.EPÜ.116'
WHERE id = '926f333d-55d2-4a12-890e-0508a4ea1bd4' AND rule_code IS NULL; -- epa.opp.boa / epa_app.oral
UPDATE paliad.deadline_rules SET rule_code = 'Art. 111 EPÜ', legal_source = 'EU.EPÜ.111'
WHERE id = 'd0949eaf-da69-4972-90c2-7e6c1bebcd79' AND rule_code IS NULL; -- epa.opp.boa / epa_app.entsch2
-- =============================================================================
-- 3. § 3 Orphan HIGH/MED (47 rows). rule_code + legal_source. For UPC
-- rows also normalize rule_codes[] to ARRAY[rule_code] so the
-- structured tooling field matches the display field. The orphan
-- archive destinations (5c0508f4 / 791fd0f7 / d886f46f) are NOT
-- filled here — they're flipped to archived in § 4.
-- =============================================================================
-- § 3.1 main-pleadings track (10 rows)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.023', legal_source = 'UPC.RoP.23.1', rule_codes = ARRAY['RoP.023']::text[]
WHERE id = 'e34097d6-670d-447a-bdfe-b42df20ba459' AND rule_code IS NULL; -- Klageerwiderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.025.1', legal_source = 'UPC.RoP.25.1', rule_codes = ARRAY['RoP.025.1']::text[]
WHERE id = '7d8a4804-0ebc-42c4-8552-624350cd81f3' AND rule_code IS NULL; -- Nichtigkeitswiderklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.b', legal_source = 'UPC.RoP.49.2.b', rule_codes = ARRAY['RoP.049.2.b']::text[]
WHERE id = 'c7523e6b-579d-4d80-afb3-e1cf11238d40' AND rule_code IS NULL; -- Verletzungswiderklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.019.1', legal_source = 'UPC.RoP.19.1', rule_codes = ARRAY['RoP.019.1']::text[]
WHERE id = 'c57f62f8-bb52-4232-be85-9125fa93f58c' AND rule_code IS NULL; -- Vorgängige Einrede
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.b', legal_source = 'UPC.RoP.29.b', rule_codes = ARRAY['RoP.029.b']::text[]
WHERE id = '84b390e0-1ca4-461a-942c-4ad94c643750' AND rule_code IS NULL; -- Replik auf Klageerwiderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c', rule_codes = ARRAY['RoP.029.c']::text[]
WHERE id = '176cc1ca-2b25-49ee-9c3e-8afed1673b7d' AND rule_code IS NULL; -- Duplik Replik Klageerwiderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.1', legal_source = 'UPC.RoP.49.1', rule_codes = ARRAY['RoP.049.1']::text[]
WHERE id = 'a32dcec1-6aaa-4a3c-936c-9a761d9362f0' AND rule_code IS NULL; -- Erwiderung auf Nichtigkeitsklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
WHERE id = '1b5c6dee-0032-4be8-864c-f2ab945aacc5' AND rule_code IS NULL; -- Duplik Replik Erwiderung Nichtigkeitsklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.1', legal_source = 'UPC.RoP.56.1', rule_codes = ARRAY['RoP.056.1']::text[]
WHERE id = 'bea86f9b-37d5-4f6e-b6bd-f0c01f053b66' AND rule_code IS NULL; -- Erwiderung auf Verletzungswiderklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.056.3', legal_source = 'UPC.RoP.56.3', rule_codes = ARRAY['RoP.056.3']::text[]
WHERE id = '4834c957-2518-40e9-ad62-447f3f220d33' AND rule_code IS NULL; -- Replik Erwiderung Verletzungswiderklage
-- § 3.2 Patentänderungs-Track (1 row; FLAG-G twin rows are handled in § 9)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.1', legal_source = 'UPC.RoP.32.1', rule_codes = ARRAY['RoP.032.1']::text[]
WHERE id = '7e65a434-f5c6-4391-a65c-d02de735f551' AND rule_code IS NULL; -- Erwiderung auf Patentänderungsantrag
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
WHERE id = 'dfd52792-840f-42c4-8b71-0f77d07cbb53' AND rule_code IS NULL; -- Replik Erwiderung Patentänderung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.032.3', legal_source = 'UPC.RoP.32.3', rule_codes = ARRAY['RoP.032.3']::text[]
WHERE id = '8cdf54eb-5189-47fd-a390-6a0ee98e5243' AND rule_code IS NULL; -- Duplik Replik Erwiderung Patentänderung
-- § 3.3 appeal track (8 fills; 2 archive-destinations handled in § 4)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.a', legal_source = 'UPC.RoP.224.1.a', rule_codes = ARRAY['RoP.224.1.a']::text[]
WHERE id = '1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818' AND rule_code IS NULL; -- Berufungsschrift canonical
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
WHERE id = 'd560b3b6-9437-4b22-b62c-957d4a37d21a' AND rule_code IS NULL; -- Berufungsschrift Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.1', legal_source = 'UPC.RoP.225.1', rule_codes = ARRAY['RoP.225.1']::text[]
WHERE id = '573df3d1-8ea2-4a6e-b0d4-fc3cd10506da' AND rule_code IS NULL; -- Berufungsbegründung canonical
UPDATE paliad.deadline_rules SET rule_code = 'RoP.224.1.b', legal_source = 'UPC.RoP.224.1.b', rule_codes = ARRAY['RoP.224.1.b']::text[]
WHERE id = '91e367dd-ffe6-4012-ac6a-b61c32e2b3b7' AND rule_code IS NULL; -- Berufung (Anordnungen & mit Zulassung)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.221.1', legal_source = 'UPC.RoP.221.1', rule_codes = ARRAY['RoP.221.1']::text[]
WHERE id = 'ccb916df-4ee3-4dde-bcb0-6a5b557c0cba' AND rule_code IS NULL; -- Berufungszulassung Kosten
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.3', legal_source = 'UPC.RoP.220.3', rule_codes = ARRAY['RoP.220.3']::text[]
WHERE id = '342e749d-c2bc-4148-974b-ac0331b76229' AND rule_code IS NULL; -- Ermessensüberprüfung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
WHERE id = '10374392-b8db-4738-8a61-f8ce0fabcc3e' AND rule_code IS NULL; -- Berufungserwiderung (224.2(a))
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.1', legal_source = 'UPC.RoP.237.1', rule_codes = ARRAY['RoP.237.1']::text[]
WHERE id = '6e39b653-1328-40e1-95f1-071fdf46eed6' AND rule_code IS NULL; -- Anschlussberufung (224.2(a))
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.1', legal_source = 'UPC.RoP.238.1', rule_codes = ARRAY['RoP.238.1']::text[]
WHERE id = '6b989e85-e739-4e3b-bfd1-52b0e0c35f61' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(a))
UPDATE paliad.deadline_rules SET rule_code = 'RoP.238.2', legal_source = 'UPC.RoP.238.2', rule_codes = ARRAY['RoP.238.2']::text[]
WHERE id = 'e78f4652-acf9-4ecd-ac48-888ce475173f' AND rule_code IS NULL; -- Erwiderung Anschlussberufung (224.2(b))
-- § 3.4 Schadensbemessung / Rechnungslegung (7 rows)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.137.2', legal_source = 'UPC.RoP.137.2', rule_codes = ARRAY['RoP.137.2']::text[]
WHERE id = 'd414f603-14c1-49f2-91be-e305eba696e3' AND rule_code IS NULL; -- Erwiderung Schadensbemessung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
WHERE id = '9f39e263-e9ec-4805-a82e-c7551a22c78d' AND rule_code IS NULL; -- Replik Schadensbemessung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.139', legal_source = 'UPC.RoP.139', rule_codes = ARRAY['RoP.139']::text[]
WHERE id = '067ffdf0-180b-488f-a369-249f6bcb9faa' AND rule_code IS NULL; -- Duplik Schadensbemessung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.2', legal_source = 'UPC.RoP.142.2', rule_codes = ARRAY['RoP.142.2']::text[]
WHERE id = '429b8ec0-227a-4945-8b20-6ad79330a490' AND rule_code IS NULL; -- Erwiderung Rechnungslegung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
WHERE id = '8d36fc76-61b9-4e99-b113-eed4c9c4b2c7' AND rule_code IS NULL; -- Replik Rechnungslegung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.142.3', legal_source = 'UPC.RoP.142.3', rule_codes = ARRAY['RoP.142.3']::text[]
WHERE id = 'ed82fec9-2346-494f-a0ff-f41e64c26942' AND rule_code IS NULL; -- Duplik Rechnungslegung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.151', legal_source = 'UPC.RoP.151', rule_codes = ARRAY['RoP.151']::text[]
WHERE id = 'eed69e8b-0dc8-4d97-83f0-5694d539b46a' AND rule_code IS NULL; -- Kostenentscheidung
-- § 3.5 provisional / PI (2 rows; canonical ba335c99 + the d886f46f archive handled in § 4)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.197.3', legal_source = 'UPC.RoP.197.3', rule_codes = ARRAY['RoP.197.3']::text[]
WHERE id = '1f1f72ef-5a67-4d6a-9a80-82e53375177a' AND rule_code IS NULL; -- Beweissicherungsanordnung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.207.9', legal_source = 'UPC.RoP.207.9', rule_codes = ARRAY['RoP.207.9']::text[]
WHERE id = '3e2f5697-3012-4bae-bd4d-44998dd3b75b' AND rule_code IS NULL; -- Schutzschrift
-- § 3.7 formalities / Registry (4 fills; 5 Mängelbeseitigung dups + FLAG-J 2 rows handled separately)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.016.5', legal_source = 'UPC.RoP.16.5', rule_codes = ARRAY['RoP.016.5']::text[]
WHERE id = '3bc40027-9ebf-4f3d-880d-bf9de6da3ec0' AND rule_code IS NULL; -- Mängelbeseitigung / Stellungnahme
UPDATE paliad.deadline_rules SET rule_code = 'RoP.262.2', legal_source = 'UPC.RoP.262.2', rule_codes = ARRAY['RoP.262.2']::text[]
WHERE id = '69e356b7-79b3-42d7-972b-44d4e35ebdbc' AND rule_code IS NULL; -- Vertraulichkeit
UPDATE paliad.deadline_rules SET rule_code = 'RoP.353', legal_source = 'UPC.RoP.353', rule_codes = ARRAY['RoP.353']::text[]
WHERE id = '57e6eeca-8695-4af3-96cc-16ebd8bc3f2c' AND rule_code IS NULL; -- Berichtigung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.333.1', legal_source = 'UPC.RoP.333.1', rule_codes = ARRAY['RoP.333.1']::text[]
WHERE id = '8ec233b9-3bc4-4015-a158-86af233e52b3' AND rule_code IS NULL; -- Verfahrensleitende Anordnung
-- § 3.8 translation / interpretation (1 row; FLAG-H/J handled in § 9 / left NULL)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.1', legal_source = 'UPC.RoP.109.1', rule_codes = ARRAY['RoP.109.1']::text[]
WHERE id = 'bb7bafcb-9d91-4bf7-ae2c-6634652d9906' AND rule_code IS NULL; -- Simultanübersetzung
-- § 3.9 review / rehearing (2 rows)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
WHERE id = '372e86e3-c8ff-4cb5-9389-66acdbc96e57' AND rule_code IS NULL; -- Wiederaufnahme (schwerwiegend)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.247.2', legal_source = 'UPC.RoP.247.2', rule_codes = ARRAY['RoP.247.2']::text[]
WHERE id = '58de9573-07db-4d8d-9b00-8fab0d71d88c' AND rule_code IS NULL; -- Wiederaufnahme (Straftat)
-- =============================================================================
-- 4. § 4 FLAG-A dedup (clean only). 1 canonical fill (the other 2
-- canonicals are filled in § 3.3) + 3 archive flips. Canonical
-- selection per m's spec: lowest UUID. None of the archive
-- candidates have FK references in mgmt.deadline_rules / paliad.
-- appointments / paliad.deadlines / paliad.deadline_rules (parent_id
-- or draft_of) — verified pre-mig. Archive over DELETE per m
-- (audit trail).
--
-- Mängelbeseitigung 6× and Beginn-Hauptsache 2× are intentionally
-- NOT deduped in this mig — see header for the deferred-decision
-- rationale. Their rows stay active+published+rule_code IS NULL
-- until m's call lands.
-- =============================================================================
-- Canonical fill for the § 123 PatG twin (legal_source already
-- DE.PatG.123.2). The other 2 canonicals (Berufungsschrift 1dfba5b1
-- and Berufungsbegründung 573df3d1) are filled in § 3.3 above.
UPDATE paliad.deadline_rules SET rule_code = '§ 123 PatG'
WHERE id = 'b588fa64-a727-4cfb-a45d-69a835a3b05a' AND rule_code IS NULL;
-- Archive flips (3 rows: the non-canonical sides of the 3 clean dedup
-- sets). After this each set has exactly 1 active+published row.
UPDATE paliad.deadline_rules
SET is_active = false, lifecycle_state = 'archived'
WHERE id IN (
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae', -- Wiedereinsetzung § 123 PatG dup
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3', -- Berufungsschrift dup
'791fd0f7-a448-4711-b1aa-63e6df1e7c57' -- Berufungsbegründung dup
)
AND is_active = true
AND lifecycle_state = 'published';
-- =============================================================================
-- 5. § 5 FLAG-B court-scheduled events (26 rows). Cite the framing norm
-- that authorises the court to schedule the event. UPC RoP.111 /
-- RoP.118 / RoP.101 / RoP.209 / RoP.211 / RoP.350 / RoP.220.1.c /
-- RoP.157. DE § 285 ZPO / § 300 ZPO / § 89 PatG / § 84 PatG / § 113
-- PatG / § 119 PatG. DPMA § 47 / 78 / 79 / 107 PatG.
-- =============================================================================
-- UPC court-scheduled events
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
WHERE id = '60d71f1e-a0e8-42cd-85e9-89f3c808868f' AND rule_code IS NULL; -- inf.decision
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
WHERE id = '7b118633-92b2-4c91-8512-6cb929288f10' AND rule_code IS NULL; -- inf.interim
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
WHERE id = 'd4c01a6f-d147-4505-bf1c-9aaf88b15287' AND rule_code IS NULL; -- inf.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.118', legal_source = 'UPC.RoP.118', rule_codes = ARRAY['RoP.118']::text[]
WHERE id = 'f382cfe4-6703-40f8-a43d-0fe02d62d0fa' AND rule_code IS NULL; -- rev.decision
UPDATE paliad.deadline_rules SET rule_code = 'RoP.101', legal_source = 'UPC.RoP.101', rule_codes = ARRAY['RoP.101']::text[]
WHERE id = 'ccad91ef-da04-4b81-a979-658578fb97c4' AND rule_code IS NULL; -- rev.interim
UPDATE paliad.deadline_rules SET rule_code = 'RoP.111', legal_source = 'UPC.RoP.111', rule_codes = ARRAY['RoP.111']::text[]
WHERE id = '38e8982b-5cc9-41b3-b477-37ce4bd4e7c4' AND rule_code IS NULL; -- rev.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209', legal_source = 'UPC.RoP.209', rule_codes = ARRAY['RoP.209']::text[]
WHERE id = 'e4a61ebf-c49b-450f-9d94-bb06098536b4' AND rule_code IS NULL; -- pi.oral
UPDATE paliad.deadline_rules SET rule_code = 'RoP.211', legal_source = 'UPC.RoP.211', rule_codes = ARRAY['RoP.211']::text[]
WHERE id = '7b93a8b7-115d-42b4-9d1d-34684ddf5206' AND rule_code IS NULL; -- pi.order
UPDATE paliad.deadline_rules SET rule_code = 'RoP.209.1', legal_source = 'UPC.RoP.209.1', rule_codes = ARRAY['RoP.209.1']::text[]
WHERE id = '30ffe572-aa77-4dcb-9292-a4750289f75c' AND rule_code IS NULL; -- pi.response (court-set)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.350', legal_source = 'UPC.RoP.350', rule_codes = ARRAY['RoP.350']::text[]
WHERE id = '685bad4f-3c3e-425d-8839-2f765d0fc96e' AND rule_code IS NULL; -- app.decision
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.1.c', legal_source = 'UPC.RoP.220.1.c', rule_codes = ARRAY['RoP.220.1.c']::text[]
WHERE id = 'c2865575-d7d6-436d-b61c-0a266217f76c' AND rule_code IS NULL; -- app_ord.order
UPDATE paliad.deadline_rules SET rule_code = 'RoP.157', legal_source = 'UPC.RoP.157', rule_codes = ARRAY['RoP.157']::text[]
WHERE id = '01db67c9-5621-48ca-9dbd-d652b6237b24' AND rule_code IS NULL; -- cost.decision
-- DE court-scheduled events
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
WHERE id = 'a95af317-2fdb-43c9-ab66-c8b2099aaa5a' AND rule_code IS NULL; -- de_inf.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
WHERE id = 'e46d2ae7-74bf-4c06-9e55-921242d36f2a' AND rule_code IS NULL; -- de_inf.urteil
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
WHERE id = '2a16f77f-408f-48c4-9d71-8ea5926d4dca' AND rule_code IS NULL; -- de_inf_olg.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
WHERE id = '7d7d88c5-895e-4855-8f4d-2e160ff74998' AND rule_code IS NULL; -- de_inf_olg.urteil_olg
UPDATE paliad.deadline_rules SET rule_code = '§ 285 ZPO', legal_source = 'DE.ZPO.285'
WHERE id = 'b1460f90-419e-47ae-978a-8e32ffafad73' AND rule_code IS NULL; -- de_inf_bgh.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 300 ZPO', legal_source = 'DE.ZPO.300'
WHERE id = '803460ac-f6bd-4194-b5ab-140175644648' AND rule_code IS NULL; -- de_inf_bgh.urteil_bgh
UPDATE paliad.deadline_rules SET rule_code = '§ 89 PatG', legal_source = 'DE.PatG.89'
WHERE id = 'ab60e712-bc56-4326-8df0-413881996bf3' AND rule_code IS NULL; -- de_null.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 84 PatG', legal_source = 'DE.PatG.84'
WHERE id = '1476829a-cc92-4221-b182-846fc99ad941' AND rule_code IS NULL; -- de_null.urteil
UPDATE paliad.deadline_rules SET rule_code = '§ 113 PatG', legal_source = 'DE.PatG.113'
WHERE id = 'd077816d-bce4-4cb7-bd67-7b52edbf7fb9' AND rule_code IS NULL; -- de_null_bgh.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 119 PatG', legal_source = 'DE.PatG.119'
WHERE id = '816e9756-efff-4e40-b650-f0b31bdc21e5' AND rule_code IS NULL; -- de_null_bgh.urteil_bgh
-- DPMA / BPatG / BGH-PatG court-scheduled events
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47'
WHERE id = '193a85e2-5794-463a-8c45-73174a54cea9' AND rule_code IS NULL; -- dpma_opp.entscheidung
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79'
WHERE id = 'baaff831-6a3f-43ed-96bb-eae6ad73f6fc' AND rule_code IS NULL; -- dpma_bpatg.entsch_bpatg
UPDATE paliad.deadline_rules SET rule_code = '§ 78 PatG', legal_source = 'DE.PatG.78'
WHERE id = '446694c2-5b34-4ecd-9bf7-7eee055b0d1b' AND rule_code IS NULL; -- dpma_bpatg.termin
UPDATE paliad.deadline_rules SET rule_code = '§ 107 PatG', legal_source = 'DE.PatG.107'
WHERE id = '99c02992-1a77-4694-b773-941ac9876bb5' AND rule_code IS NULL; -- dpma_bgh.entsch_bgh
-- =============================================================================
-- 6. § 6 FLAG-C/D rubber-stamp (5 rows). UPC RoP duration-vs-norm
-- mismatches get the canonical citation per m ("just go ahead"). DE
-- LG patent-practice 4-week replik/duplik cite § 273 ZPO (court-set
-- framing).
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
WHERE id = '7e0ea937-d81b-4dee-897e-0d8bc0543f34' AND rule_code IS NULL; -- rev.reply (FLAG-C)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.052', legal_source = 'UPC.RoP.52', rule_codes = ARRAY['RoP.052']::text[]
WHERE id = 'b7890351-c6d6-46e4-b064-0513a1808e6d' AND rule_code IS NULL; -- rev.rejoin (FLAG-C)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.1', legal_source = 'UPC.RoP.235.1', rule_codes = ARRAY['RoP.235.1']::text[]
WHERE id = 'd6600ceb-d1d5-408a-a7c9-1026f304ac7f' AND rule_code IS NULL; -- app.response (FLAG-C)
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
WHERE id = 'd46d915e-fd46-4167-88b5-6d22bcbb8882' AND rule_code IS NULL; -- de_inf.replik (FLAG-D)
UPDATE paliad.deadline_rules SET rule_code = '§ 273 ZPO', legal_source = 'DE.ZPO.273'
WHERE id = 'ca9b52cb-e986-4c3a-9e89-e799e6a6ac33' AND rule_code IS NULL; -- de_inf.duplik (FLAG-D)
-- =============================================================================
-- 7. § 7 FLAG-E service triggers (6 rows, DE/EPA). § 317 ZPO for LG/OLG
-- judgment-service, § 99 / § 47 / § 79 PatG for the PatG variants,
-- R. 111 EPÜ for EPA notification.
-- =============================================================================
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
WHERE id = '106d8a0b-514b-4021-8b65-7debff71f1d3' AND rule_code IS NULL; -- de_inf_olg.urteil_lg
UPDATE paliad.deadline_rules SET rule_code = '§ 317 ZPO', legal_source = 'DE.ZPO.317'
WHERE id = 'd071b5c6-f33e-44e8-8656-4e9cccf55701' AND rule_code IS NULL; -- de_inf_bgh.urteil_olg
UPDATE paliad.deadline_rules SET rule_code = '§ 99 PatG', legal_source = 'DE.PatG.99.1'
WHERE id = 'bdae7319-7435-40e9-be19-6ce21fdb9946' AND rule_code IS NULL; -- de_null_bgh.urteil_bpatg
UPDATE paliad.deadline_rules SET rule_code = '§ 47 PatG', legal_source = 'DE.PatG.47.1'
WHERE id = '327390f9-3c1b-496f-8e63-2bf19c380dfe' AND rule_code IS NULL; -- dpma_bpatg.entscheidung
UPDATE paliad.deadline_rules SET rule_code = '§ 79 PatG', legal_source = 'DE.PatG.79.1'
WHERE id = 'd3ea5e50-f7e2-40f1-bb16-30664acc2e2b' AND rule_code IS NULL; -- dpma_bgh.entsch_bpatg
UPDATE paliad.deadline_rules SET rule_code = 'R. 111 EPÜ', legal_source = 'EU.EPC-R.111'
WHERE id = '79c27f9b-5195-4272-90d6-ea6a43cd0938' AND rule_code IS NULL; -- epa_app.entsch
-- =============================================================================
-- 8. § 8 FLAG-F combined-pleading rows (5 rows). Primary cite in
-- rule_code + legal_source; full set of citations in rule_codes[]
-- so downstream tooling can resolve any of the combined norms.
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.029.a', legal_source = 'UPC.RoP.29.a',
rule_codes = ARRAY['RoP.029.a', 'RoP.029.b']::text[]
WHERE id = 'cec1a865-30a4-46c9-8abf-630d4478b91a' AND rule_code IS NULL; -- Erwid CCR + Replik SoD
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.029.c', legal_source = 'UPC.RoP.29.c',
rule_codes = ARRAY['RoP.029.c', 'RoP.032.3']::text[]
WHERE id = '02ae9c1f-2aa0-4e0e-acf1-ae235588a64f' AND rule_code IS NULL; -- Duplik Replik + Replik Erwid Patentänderung
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.029.d', legal_source = 'UPC.RoP.29.d',
rule_codes = ARRAY['RoP.029.d', 'RoP.029.c', 'RoP.032.1']::text[]
WHERE id = 'ec2a1274-ffd8-42e7-9e27-582365d04d6e' AND rule_code IS NULL; -- Replik Erwid Widerklage + Duplik Replik Klageerwid + Erwid Patentänderung
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.051', legal_source = 'UPC.RoP.51',
rule_codes = ARRAY['RoP.051', 'RoP.049.2.a', 'RoP.056.1']::text[]
WHERE id = '37bd034b-79e3-4c3c-a21d-b078aaf2ea04' AND rule_code IS NULL; -- Replik Erwid Nichtigkeit + Erwid Patent + Erwid Widerklage
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.056.4', legal_source = 'UPC.RoP.56.4',
rule_codes = ARRAY['RoP.056.4', 'RoP.032.3']::text[]
WHERE id = '7b548c48-6fef-4387-8123-e1f1e4ee6da2' AND rule_code IS NULL; -- Duplik (Verletzungswiderklage + Patentänderung)
-- =============================================================================
-- 9. § 9 FLAG-G/H/I + RoP.271.b. Patentänderung INF/REV split (G),
-- sub-paragraph spot-checks (H, applied as-is per doc), negative-
-- declaration RoP.069 by analogy (I), and the RoP.271.b 10-day
-- service-deferral secondary cite on UPC initial submissions.
-- =============================================================================
-- FLAG-G: Patentänderungs-Twin (INF vs REV context)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.030.1', legal_source = 'UPC.RoP.30.1', rule_codes = ARRAY['RoP.030.1']::text[]
WHERE id = 'fb7050c6-a18b-47e4-8811-46ca3677d549' AND rule_code IS NULL; -- Patentänderung INF
UPDATE paliad.deadline_rules SET rule_code = 'RoP.049.2.a', legal_source = 'UPC.RoP.49.2.a', rule_codes = ARRAY['RoP.049.2.a']::text[]
WHERE id = '21e67ac1-fe40-44d1-ae2e-ea90e0b97598' AND rule_code IS NULL; -- Patentänderung REV
-- FLAG-H: sub-paragraph spot-checks (8 rows, applied per doc proposal)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.225.2', legal_source = 'UPC.RoP.225.2', rule_codes = ARRAY['RoP.225.2']::text[]
WHERE id = 'c3a369f9-4f56-4c88-b11c-f98d05d3b376' AND rule_code IS NULL; -- Berufungsbegründung Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.234.1', legal_source = 'UPC.RoP.234.1', rule_codes = ARRAY['RoP.234.1']::text[]
WHERE id = 'd4f739cd-444d-48c0-98c4-70f0521b4916' AND rule_code IS NULL; -- Anfechtung Verwerfung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.235.4', legal_source = 'UPC.RoP.235.4', rule_codes = ARRAY['RoP.235.4']::text[]
WHERE id = '4c585c6d-fb5c-4a99-a798-86a05c757bf7' AND rule_code IS NULL; -- Berufungserwiderung Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.237.2', legal_source = 'UPC.RoP.237.2', rule_codes = ARRAY['RoP.237.2']::text[]
WHERE id = 'a00e51bb-bcb6-48d0-9aa5-2216e9480c5c' AND rule_code IS NULL; -- Anschlussberufung Orders
UPDATE paliad.deadline_rules SET rule_code = 'RoP.097.1', legal_source = 'UPC.RoP.97.1', rule_codes = ARRAY['RoP.097.1']::text[]
WHERE id = '0531b6ba-98cc-48f4-adb8-da8b7a7c3535' AND rule_code IS NULL; -- Aufhebung EPA Einheitswirkung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.037.4', legal_source = 'UPC.RoP.37.4', rule_codes = ARRAY['RoP.037.4']::text[]
WHERE id = '6b6b967c-65fd-4172-9640-1ffff8a46704' AND rule_code IS NULL; -- Verweisung Zentralkammer
UPDATE paliad.deadline_rules SET rule_code = 'RoP.109.5', legal_source = 'UPC.RoP.109.5', rule_codes = ARRAY['RoP.109.5']::text[]
WHERE id = '8c682cff-3423-41d8-81ca-b5b461461682' AND rule_code IS NULL; -- Dolmetscher own-cost
UPDATE paliad.deadline_rules SET rule_code = 'RoP.007.2', legal_source = 'UPC.RoP.7.2', rule_codes = ARRAY['RoP.007.2']::text[]
WHERE id = '9ed513c1-68df-455e-810e-a5d8d7b85729' AND rule_code IS NULL; -- Übersetzungen Schriftstücke
-- FLAG-I: negative-declaration track (3 rows, RoP.069 by analogy per m)
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
WHERE id = '521bf607-1c69-4dc5-a09e-70339bbe4684' AND rule_code IS NULL; -- Erwid neg. Feststellungsklage
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
WHERE id = 'e887b1fb-83ff-4073-b81b-c10dde6dc2c6' AND rule_code IS NULL; -- Replik neg. Feststellung
UPDATE paliad.deadline_rules SET rule_code = 'RoP.069', legal_source = 'UPC.RoP.69', rule_codes = ARRAY['RoP.069']::text[]
WHERE id = '0cf1d755-3ba5-44ce-87ca-f98bb076c995' AND rule_code IS NULL; -- Duplik neg. Feststellung
-- RoP.271.b — 10-day service deferral on UPC initial submissions.
-- Set rule_codes[] to [primary substantive cite, 'RoP.271.b'] for the
-- 5 UPC initial-submission rows whose § 2 UPDATEs above only set
-- rule_code + legal_source. Idempotent via the IS DISTINCT FROM guard
-- — re-running matches no rows.
UPDATE paliad.deadline_rules
SET rule_codes = target.rule_codes
FROM (VALUES
('42be6c9b-8e84-4804-962f-94c3315aca1b'::uuid, ARRAY['RoP.013.1', 'RoP.271.b']::text[]), -- inf.soc
('995c108e-e73a-4f9c-b79f-47abe7c94108'::uuid, ARRAY['RoP.042', 'RoP.271.b']::text[]), -- rev.app
('ed0194b7-74ab-4402-8971-7211f6036ff9'::uuid, ARRAY['RoP.206', 'RoP.271.b']::text[]), -- pi.app
('3e1719e8-f6f6-4260-8f02-754bd214937f'::uuid, ARRAY['RoP.131', 'RoP.271.b']::text[]), -- damages.app
('eb1fa1d1-b345-42ba-ab14-79f5284166b0'::uuid, ARRAY['RoP.141', 'RoP.271.b']::text[]) -- disc.app
) AS target(id, rule_codes)
WHERE paliad.deadline_rules.id = target.id
AND paliad.deadline_rules.rule_codes IS DISTINCT FROM target.rule_codes;
-- =============================================================================
-- 10. § 10 R.19 label rename (inf.prelim / rev.prelim). Defensive
-- idempotent backstop for fermi's live prod write. Matches no rows
-- on the current prod DB (fermi already renamed) and on the first
-- post-mig fresh-deploy too. Catches any future prod that hasn't
-- seen the live write.
-- =============================================================================
UPDATE paliad.deadline_rules
SET name = 'Einspruch (R. 19 VerfO)', rule_code = 'RoP.019.1'
WHERE code = 'inf.prelim' AND name LIKE 'Vorab-Einrede%';
UPDATE paliad.deadline_rules
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)', rule_code = 'RoP.019.1'
WHERE code = 'rev.prelim' AND name LIKE 'Vorab-Einrede%';
-- =============================================================================
-- 11. § 11 Side-fix: normalize the one un-padded UPC RoP <100 rule_code
-- outlier. legal_source stays 'UPC.RoP.49.1' (structured locator
-- never pads — convention § 0.2 of the proposal doc).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.049.1'
WHERE rule_code = 'RoP.49.1'
AND code = 'rev.defence';
-- =============================================================================
-- 12. Refresh the deadline_search materialized view so search hits
-- return the newly populated rule_code + legal_source values.
-- =============================================================================
REFRESH MATERIALIZED VIEW paliad.deadline_search;
-- =============================================================================
-- 13. Hard assertions. Verifies the post-state matches the plan.
--
-- a) 11 active+published rows remain rule_code IS NULL: the 3
-- FLAG-J rows (m picks them up via /admin/rules) plus the 8
-- rows whose dedup decision is deferred (Mängelbeseitigung 6×
-- + Beginn-Hauptsache 2×).
-- b) No un-padded RoP.49.1 outlier remains.
-- c) Padded RoP.049.1 present at least twice (rev.defence
-- normalized + a32dcec1 orphan filled).
-- d) Each of the 3 clean-dedup sets has exactly 1 active+published
-- row after the archive flips.
-- =============================================================================
DO $$
DECLARE
v_null_after integer;
v_old_outlier integer;
v_new_padded integer;
v_dup_count integer;
BEGIN
-- (a) 3 FLAG-J + 8 deferred-dedup rows stay NULL.
SELECT count(*) INTO v_null_after
FROM paliad.deadline_rules
WHERE rule_code IS NULL
AND is_active = true
AND lifecycle_state = 'published';
IF v_null_after <> 11 THEN
RAISE EXCEPTION
'mig 097: expected 11 rule_code IS NULL active+published rows after backfill (3 FLAG-J + 8 deferred dedup), got %',
v_null_after;
END IF;
-- (b) RoP.49.1 outlier normalized.
SELECT count(*) INTO v_old_outlier
FROM paliad.deadline_rules
WHERE rule_code = 'RoP.49.1';
IF v_old_outlier <> 0 THEN
RAISE EXCEPTION
'mig 097: expected 0 RoP.49.1 rows after normalization, got %',
v_old_outlier;
END IF;
-- (c) RoP.049.1 present at least twice.
SELECT count(*) INTO v_new_padded
FROM paliad.deadline_rules
WHERE rule_code = 'RoP.049.1';
IF v_new_padded < 2 THEN
RAISE EXCEPTION
'mig 097: expected >= 2 RoP.049.1 rows after normalization + orphan fill, got %',
v_new_padded;
END IF;
-- (d) Each clean-dedup set has exactly 1 active+published row.
SELECT count(*) INTO v_dup_count
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND id IN (
'b588fa64-a727-4cfb-a45d-69a835a3b05a',
'c24d494c-0da1-4f01-aa74-0f37f99fe1ae'
);
IF v_dup_count <> 1 THEN
RAISE EXCEPTION
'mig 097 dedup: Wiedereinsetzung-§123-PatG set must have 1 active+published row, got %',
v_dup_count;
END IF;
SELECT count(*) INTO v_dup_count
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND id IN (
'1dfba5b1-4ed1-40c1-9cf6-4ed8ff7a0818',
'5c0508f4-020a-4ef5-bcc7-1ee85eafe0b3'
);
IF v_dup_count <> 1 THEN
RAISE EXCEPTION
'mig 097 dedup: Berufungsschrift set must have 1 active+published row, got %',
v_dup_count;
END IF;
SELECT count(*) INTO v_dup_count
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND id IN (
'573df3d1-8ea2-4a6e-b0d4-fc3cd10506da',
'791fd0f7-a448-4711-b1aa-63e6df1e7c57'
);
IF v_dup_count <> 1 THEN
RAISE EXCEPTION
'mig 097 dedup: Berufungsbegründung set must have 1 active+published row, got %',
v_dup_count;
END IF;
END $$;

View File

@@ -0,0 +1,162 @@
-- Reverses mig 098. Restores the pre-098 submission codes on
-- paliad.deadline_rules, renames the column back to `code`, recreates
-- the deadline_search matview against the restored column, then drops
-- the snapshot table.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098 (down): revert t-paliad-209 workstream B — restore paliad.deadline_rules.code values from deadline_rules_pre_098 snapshot and rename submission_code → code; matview deadline_search rebuilt against the restored column.',
true);
-- =============================================================================
-- 1. Drop the matview so the column rename can succeed.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 2. Rename the column back. Guarded so a down run on a DB where the
-- up never ran (or where the column is already named `code`) is a
-- no-op rather than an error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN submission_code TO code;
END IF;
END $$;
-- =============================================================================
-- 3. Restore code values from the pre_098 snapshot. The snapshot was
-- captured at the first up-migration run; if the table is missing
-- (down run before up), the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_098'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 098 (down): snapshot table paliad.deadline_rules_pre_098 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET code = snap.code
FROM paliad.deadline_rules_pre_098 snap
WHERE dr.id = snap.id
AND dr.code <> snap.code;
END $$;
-- =============================================================================
-- 4. Recreate the deadline_search matview against the restored column.
-- Identical body to mig 051 §4, reproduced here so the down leaves
-- the schema in the same shape mig 051 created.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
-- =============================================================================
-- 5. Drop the snapshot table so a re-applied up captures a fresh
-- snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_098;

View File

@@ -0,0 +1,275 @@
-- t-paliad-209 / workstream B — submission-code prefix + rename.
--
-- m's 2026-05-18 call: the `paliad.deadline_rules.code` field is a
-- SUBMISSION identifier (the event/filing within a proceeding), not the
-- legal-citation rule code (which lives in `rule_code` / `legal_source`).
-- Two cleanups land here:
--
-- 1. DATA — prefix every existing submission code with its proceeding
-- code so submission codes carry the full hierarchical shape
-- (e.g. `inf.soc` on `upc.inf.cfi` → `upc.inf.cfi.soc`,
-- `de_inf.klage` on `de.inf.lg` → `de.inf.lg.klage`).
-- Algorithm: keep the proceeding-code prefix as-is, strip the
-- old single-segment prefix (everything before the first dot in
-- `dr.code`) and replace it with the proceeding's full `code`.
--
-- 2. SCHEMA — rename `paliad.deadline_rules.code` → `submission_code`
-- so future devs don't conflate it with `rule_code` (legal
-- citation) or `proceeding_types.code`. Explicit name encodes the
-- semantic taxonomy ratified in
-- docs/design-proceeding-code-taxonomy-2026-05-18.md §0.1.
--
-- Materialized-view dependency: `paliad.deadline_search` (mig 051) has
-- `dr.code AS rule_local_code` baked into its SELECT list. Postgres
-- rejects RENAME COLUMN when a matview's column list still resolves
-- via the old name — so the matview is dropped before the rename and
-- recreated against `submission_code` afterwards, with every index
-- reproduced. The mig 047 / 051 indexes are reproduced verbatim here.
--
-- IDs and FKs are untouched. `deadline_rules.proceeding_type_id` /
-- `parent_id` / `spawn_proceeding_type_id` reference ids; no
-- code-string FK exists on submission codes (the parent_id chain is on
-- UUID `id`, not the code string), so the data UPDATE doesn't risk
-- breaking joins.
--
-- Idempotent:
-- * The data UPDATE is gated `WHERE dr.code NOT LIKE pt.code || '.%'`
-- — rows already prefixed with their proceeding code (i.e. the
-- migration ran before) are skipped.
-- * The rename is wrapped in a DO block that checks column existence,
-- so a second run is a no-op.
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
-- * Matview drop/recreate is DROP IF EXISTS + CREATE.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098: t-paliad-209 workstream B — prefix every paliad.deadline_rules.code with its proceeding code, then rename code → submission_code; matview deadline_search rebuilt against the new column. See docs/design-proceeding-code-taxonomy-2026-05-18.md and the t-paliad-209 task brief.',
true);
-- =============================================================================
-- 1. Backup snapshot of paliad.deadline_rules BEFORE the prefix + rename.
-- Captures the rows as they are; serves as the source for the down
-- migration and the permanent audit anchor.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_098 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_098 IS
'Snapshot of paliad.deadline_rules taken before mig 098 prefixed '
'every `code` with its proceeding code and renamed the column to '
'`submission_code` (t-paliad-209, 2026-05-18). Source-of-truth '
'for the down migration; persists post-rename as the permanent '
'audit record.';
-- =============================================================================
-- 2. Drop the deadline_search materialized view. It bakes `dr.code AS
-- rule_local_code` into its SELECT list (mig 051 §4), and Postgres
-- refuses to rename a column that a matview's column list still
-- resolves via the old name. The matview is recreated verbatim in §5
-- against the renamed column.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 3. Data UPDATE — prefix every submission code with its proceeding
-- code. Algorithm:
-- * proceeding_code = pt.code
-- * suffix = portion of dr.code after the first '.'
-- * new code = proceeding_code || '.' || suffix
--
-- regexp_replace('inf.soc', '^[^.]+\.', '') = 'soc'
-- regexp_replace('de_inf_bgh.revision', ...) = 'revision'
--
-- The WHERE clause skips rows that already start with `pt.code || '.'`
-- so re-running the migration is a no-op on already-prefixed rows.
-- Archived rows (proceeding `_archived_litigation`) get the same
-- treatment — they end up as `_archived_litigation.<suffix>`. The
-- shape regex in §6 only inspects active+published rows, so the
-- archived form sits outside the constraint by design.
-- =============================================================================
UPDATE paliad.deadline_rules dr
SET code = pt.code || '.' || regexp_replace(dr.code, '^[^.]+\.', '')
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND dr.code IS NOT NULL
AND position('.' in dr.code) > 0
AND dr.code NOT LIKE pt.code || '.%';
-- =============================================================================
-- 4. Rename the column. Guarded in a DO block so a second run (e.g. a
-- fresh DB built up to mig 098 from an empty schema, or a manual
-- re-apply) is a no-op rather than a hard error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN code TO submission_code;
END IF;
END $$;
-- =============================================================================
-- 5. Recreate the deadline_search matview against the renamed column.
-- Column list reproduced verbatim from mig 051 §4 with the single
-- edit: `dr.code AS rule_local_code` → `dr.submission_code AS
-- rule_local_code`. All indexes from mig 051 are reproduced too.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
-- =============================================================================
-- 6. Hard assertions. Half-applied migrations would leave the rule
-- corpus inconsistent; gate on the shape of every active+published
-- row and on column existence so this fails loudly rather than
-- leaving the schema in a half-renamed state.
-- =============================================================================
DO $$
DECLARE
v_bad_shape integer;
v_null_codes integer;
v_col_exists boolean;
BEGIN
-- 6.1 Every active+published row has the proceeding-code-prefixed
-- 4+-segment shape. Archived rows (`_archived_litigation` ones)
-- keep their shorter shape by design — they're carved out.
-- Suffix segments may include digits (existing data — e.g. EPA rule
-- codes like `epa.opp.boa.r106` / `epa.grant.exa.r71_3` carry the
-- statutory rule number in the suffix). Allow [a-z_0-9] per segment.
SELECT count(*) INTO v_bad_shape
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND submission_code !~ '^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+(\..*)?$';
IF v_bad_shape <> 0 THEN
RAISE EXCEPTION
'mig 098: expected every active+published deadline_rules row to match the 4+-segment submission_code shape, got % violators',
v_bad_shape;
END IF;
-- 6.2 No NULL submission_code on active+published rows that BELONG
-- to a proceeding. Orphan rows (`proceeding_type_id IS NULL`)
-- are cross-cutting rules without a fixed proceeding home
-- (Wiedereinsetzung, Schriftsatznachreichung, etc.) — they
-- legitimately carry NULL submission_code because there's no
-- proceeding to prefix with. Exempt them.
SELECT count(*) INTO v_null_codes
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND proceeding_type_id IS NOT NULL
AND submission_code IS NULL;
IF v_null_codes <> 0 THEN
RAISE EXCEPTION
'mig 098: expected 0 NULL submission_code on active+published rows, got %',
v_null_codes;
END IF;
-- 6.3 Column was actually renamed. Catches the case where the DO
-- guard in §4 short-circuited because the schema hadn't yet
-- been migrated.
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) INTO v_col_exists;
IF NOT v_col_exists THEN
RAISE EXCEPTION
'mig 098: column paliad.deadline_rules.submission_code missing after rename — half-applied migration';
END IF;
END $$;

View File

@@ -0,0 +1,10 @@
-- Revert mig 098 — restore the with_po condition_expr (mig 095 shape).
-- audit_reason required: set via SET LOCAL paliad.audit_reason in tooling.
UPDATE paliad.deadline_rules dr
SET condition_expr = '{"flag":"with_po"}'::jsonb
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code IN ('upc.inf.cfi', 'upc.rev.cfi')
AND dr.rule_code = 'RoP.019.1'
AND dr.condition_expr IS NULL;

View File

@@ -0,0 +1,34 @@
-- t-paliad-207 — drop the `with_po` flag from the two RoP 19 rules.
-- m's call 2026-05-18 (interactive session): the Einspruch (R. 19) is
-- not flag-gated — it's just an optional submission the defendant can
-- always make, triggered by the SoC. Same reasoning that drove the
-- always-fire decision for the appeal-spawn rules in t-paliad-203 F2.3
-- ("appeal is always a possibility").
--
-- Net effect: the calculator will surface the R.19 row on every UPC_INF
-- / UPC_REV calc as an optional row (priority='optional' already set
-- by mig 095, unchanged here). The save-modal pre-uncheck behaviour
-- for optional priority handles the "user opts in" gesture without a
-- separate flag.
--
-- Two rows updated; pinned by proceeding code so this stays correct
-- after any rule-id reshuffle. Idempotent: the WHERE clause matches
-- the live shape, so re-apply is a no-op.
--
-- audit_reason set_config required at the top — the mig 079 trigger
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
-- on any UPDATE without it. Original mig 099 author missed this and
-- crash-looped paliad prod; this is the recovery patch.
SELECT set_config(
'paliad.audit_reason',
'mig 099: drop with_po condition_expr on the two RoP.019.1 rows — m''s call 2026-05-18 (t-paliad-207 interactive session), R.19 Einspruch is always-available not flag-gated',
true);
UPDATE paliad.deadline_rules dr
SET condition_expr = NULL
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code IN ('upc.inf.cfi', 'upc.rev.cfi')
AND dr.rule_code = 'RoP.019.1'
AND dr.condition_expr::text LIKE '%with_po%';

View File

@@ -0,0 +1,26 @@
-- Revert mig 100 — remove the upc.inf.cfi.ccr informational rule and
-- restore the sequence_order values of def_to_ccr / app_to_amend.
SELECT set_config(
'paliad.audit_reason',
'mig 100 down: revert upc.inf.cfi.ccr informational rule + sequence reshuffle',
true);
UPDATE paliad.deadline_rules
SET sequence_order = 12
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 13;
UPDATE paliad.deadline_rules
SET sequence_order = 11
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 12;
DELETE FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published';

View File

@@ -0,0 +1,97 @@
-- t-paliad-207 — make the Nichtigkeitswiderklage (CCR) visible in the
-- calculator output when the `with_ccr` flag is set. m's observation
-- 2026-05-18 (interactive session): toggling "Mit Nichtigkeitswider-
-- klage" surfaces the response rules (def_to_ccr, reply, rejoin, …)
-- but the triggering event itself — the act of filing the CCR — is
-- invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement
-- of Defence with the same 3-month deadline, so the corpus author
-- (mig 028) skipped it. UX is the problem: users see consequences
-- without the cause.
--
-- Net effect: a new `upc.inf.cfi.ccr` row with priority='informational'
-- renders the CCR as a notice card on the timeline (no save action,
-- no extra deadline-to-track; the SoD's deadline already covers it).
-- Date is identical to the SoD (3 months from SoC, same anchor +
-- duration). condition_expr={"flag":"with_ccr"} so the row only appears
-- when the user has flagged that a CCR is being filed.
--
-- Sequence reshuffle: inserting at sequence_order=11 pushes
-- def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
-- SoD → CCR → def_to_ccr → app_to_amend (cause before effect). The
-- two UPDATEs are guarded by the SOURCE values so re-apply is a no-op.
--
-- audit_reason set_config required at the top — the deadline_rules
-- audit trigger raises EXCEPTION 'audit reason required' on any
-- mutation without it (cf. mig 099 hotfix history).
--
-- Idempotency:
-- * INSERT uses NOT EXISTS keyed on (proceeding_type_id,
-- submission_code, lifecycle_state='published').
-- * UPDATEs are guarded by current sequence_order value.
SELECT set_config(
'paliad.audit_reason',
'mig 100: add upc.inf.cfi.ccr informational rule so CCR filing event is visible when with_ccr flag is set (m''s 2026-05-18 ask, t-paliad-207 interactive session)',
true);
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.soc'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND is_active = true),
'upc.inf.cfi.ccr',
'Nichtigkeitswiderklage',
'Counterclaim for Revocation',
'Widerklage des Beklagten auf Nichtigkeit des Klagepatents. Wird gemeinsam mit der Klageerwiderung (Statement of Defence) eingereicht (R.25 VerfO); selbe Frist von 3 Monaten ab Zustellung der Klage. Eigener adversarialer Schriftsatz, der die Folge-Schriftsätze (Erwiderung auf Nichtigkeitswiderklage, Replik, Duplik) auslöst.',
'defendant',
'filing',
3,
'months',
'after',
'RoP.025',
'Wird mit der Klageerwiderung eingereicht (R.25 VerfO); kein separater Fristtermin — selbes Datum wie die Klageerwiderung. Wird informativ angezeigt, damit der auslösende Schriftsatz für die Folgefristen sichtbar bleibt.',
'Filed together with the Statement of Defence (RoP 25); no separate deadline — same date as the SoD. Surfaced informationally so the triggering submission for the downstream deadlines is visible.',
11,
false,
NULL,
NULL,
true,
'UPC.RoP.25.1',
false,
'{"flag":"with_ccr"}'::jsonb,
'informational',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published');
-- Sequence reshuffle: bump def_to_ccr and app_to_amend by 1 so the
-- new ccr row at 11 sits between SoD (10) and def_to_ccr. Guarded by
-- the source values to keep idempotency.
UPDATE paliad.deadline_rules
SET sequence_order = 12
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 11;
UPDATE paliad.deadline_rules
SET sequence_order = 13
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 12;

View File

@@ -0,0 +1,52 @@
-- Revert mig 101 — restore the bracket-bearing Einspruch names and
-- flip the CCR priority back to 'informational'.
SELECT set_config(
'paliad.audit_reason',
'mig 101 down: restore "Einspruch (R. 19 VerfO)" and "Einspruch (R. 19 i.V.m. R. 46 VerfO)" names + flip upc.inf.cfi.ccr priority back to informational',
true);
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection';
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch';
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection (RoP 19)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection';
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch (R. 19 VerfO)'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch';
UPDATE paliad.deadline_rules dr
SET priority = 'informational'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.ccr'
AND dr.lifecycle_state = 'published'
AND dr.priority = 'optional';

View File

@@ -0,0 +1,89 @@
-- t-paliad-207 (m's interactive session) — two label/priority polish
-- fixes on upc.inf.cfi / upc.rev.cfi:
--
-- 1. **CCR priority informational → optional.** m's correction
-- 2026-05-18 18:01: the Nichtigkeitswiderklage is a substantive
-- defensive choice the defendant makes — not just an informational
-- notice. priority='optional' renders it as an unchecked save row
-- the user can opt into. The fermi amend (commit e8d658a) flipping
-- this didn't land in main — paliadin's merge of mig 100 (commit
-- c10f8cf, merge 4ddcd28) picked up the pre-amend 'informational'
-- version. This is the recovery.
--
-- 2. **Strip rule citation from Einspruch names.** m's correction
-- 2026-05-18 18:08: every other rule name in the corpus carries
-- the act-name without a parenthetical rule cite (Klageerwiderung,
-- Antrag auf Patentänderung, Replik, etc.). The Einspruch rule
-- names are the outliers:
-- upc.inf.cfi.prelim "Einspruch (R. 19 VerfO)" → "Einspruch"
-- upc.rev.cfi.prelim "Einspruch (R. 19 i.V.m. R. 46 VerfO)" → "Einspruch"
-- and EN equivalents:
-- "Preliminary Objection (RoP 19)" → "Preliminary Objection"
-- "Preliminary Objection (RoP 19 in conjunction with RoP 46)"
-- → "Preliminary Objection"
-- The legal_source / rule_code columns already carry the citation
-- and render in the deadline card's meta line, so the name stays
-- clean. The R.46-i.V.m. distinction is preserved in the legal
-- source field (RoP.019.1 for both — m may want to further
-- differentiate; flagged in description text instead).
--
-- audit_reason set_config required at the top — the deadline_rules
-- audit trigger raises EXCEPTION 'audit reason required' on any
-- mutation without it (cf. mig 099 hotfix history).
--
-- Idempotency:
-- * Priority UPDATE guarded on the current 'informational' value.
-- * Name UPDATEs guarded on the current parenthetical-bearing names.
SELECT set_config(
'paliad.audit_reason',
'mig 101: flip upc.inf.cfi.ccr priority informational→optional + strip rule-cite brackets from R.19 Einspruch names on both upc.inf.cfi.prelim and upc.rev.cfi.prelim (m''s corrections 2026-05-18, t-paliad-207 interactive session)',
true);
-- 1) Flip CCR priority
UPDATE paliad.deadline_rules dr
SET priority = 'optional'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.ccr'
AND dr.lifecycle_state = 'published'
AND dr.priority = 'informational';
-- 2a) Strip "(R. 19 VerfO)" from upc.inf.cfi.prelim DE/EN names
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch (R. 19 VerfO)';
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.inf.cfi'
AND dr.submission_code = 'upc.inf.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection (RoP 19)';
-- 2b) Strip "(R. 19 i.V.m. R. 46 VerfO)" from upc.rev.cfi.prelim DE/EN names
UPDATE paliad.deadline_rules dr
SET name = 'Einspruch'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name = 'Einspruch (R. 19 i.V.m. R. 46 VerfO)';
UPDATE paliad.deadline_rules dr
SET name_en = 'Preliminary Objection'
FROM paliad.proceeding_types pt
WHERE dr.proceeding_type_id = pt.id
AND pt.code = 'upc.rev.cfi'
AND dr.submission_code = 'upc.rev.cfi.prelim'
AND dr.lifecycle_state = 'published'
AND dr.name_en = 'Preliminary Objection (RoP 19 in conjunction with RoP 46)';

View File

@@ -0,0 +1,31 @@
-- Revert mig 102 — restore the pre-mig-102 sequence_order values
-- (post-mig-100 state). Same two-phase swap pattern.
SELECT set_config(
'paliad.audit_reason',
'mig 102 down: restore pre-track-aware sequence_order on upc.inf.cfi rules',
true);
-- Phase 1: park
UPDATE paliad.deadline_rules SET sequence_order = 1011 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 20;
UPDATE paliad.deadline_rules SET sequence_order = 1012 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 22;
UPDATE paliad.deadline_rules SET sequence_order = 1013 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 30;
UPDATE paliad.deadline_rules SET sequence_order = 1020 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 12;
UPDATE paliad.deadline_rules SET sequence_order = 1021 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 32;
UPDATE paliad.deadline_rules SET sequence_order = 1022 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 24;
UPDATE paliad.deadline_rules SET sequence_order = 1030 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 14;
UPDATE paliad.deadline_rules SET sequence_order = 1031 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 34;
UPDATE paliad.deadline_rules SET sequence_order = 1032 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 26;
UPDATE paliad.deadline_rules SET sequence_order = 1033 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 36;
-- Phase 2: assign originals
UPDATE paliad.deadline_rules SET sequence_order = 11 WHERE submission_code = 'upc.inf.cfi.ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1011;
UPDATE paliad.deadline_rules SET sequence_order = 12 WHERE submission_code = 'upc.inf.cfi.def_to_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1012;
UPDATE paliad.deadline_rules SET sequence_order = 13 WHERE submission_code = 'upc.inf.cfi.app_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1013;
UPDATE paliad.deadline_rules SET sequence_order = 20 WHERE submission_code = 'upc.inf.cfi.reply' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1020;
UPDATE paliad.deadline_rules SET sequence_order = 21 WHERE submission_code = 'upc.inf.cfi.def_to_amend' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1021;
UPDATE paliad.deadline_rules SET sequence_order = 22 WHERE submission_code = 'upc.inf.cfi.reply_def_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1022;
UPDATE paliad.deadline_rules SET sequence_order = 30 WHERE submission_code = 'upc.inf.cfi.rejoin' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1030;
UPDATE paliad.deadline_rules SET sequence_order = 31 WHERE submission_code = 'upc.inf.cfi.reply_def_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1031;
UPDATE paliad.deadline_rules SET sequence_order = 32 WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1032;
UPDATE paliad.deadline_rules SET sequence_order = 33 WHERE submission_code = 'upc.inf.cfi.rejoin_amd' AND proceeding_type_id = 8 AND lifecycle_state = 'published' AND sequence_order = 1033;

View File

@@ -0,0 +1,211 @@
-- t-paliad-207 — re-sequence upc.inf.cfi rules so within any tied-date
-- group the infringement-track responses sit ABOVE the revocation-
-- track responses ABOVE the amendment-track responses. m's ask
-- 2026-05-18 18:08: "the infringement parts (like Replik) should show
-- above the part for the revocation (Erwiderung Nichtigkeitswider-
-- klage)".
--
-- Three tracks coexist on upc.inf.cfi once the with_ccr / with_amend
-- flags are set. They share calendar dates because R.29 / R.30 / R.32
-- all key off the SoD or its descendants. The current sequence_orders
-- (post-mig 100) interleave them; the user sees Erwiderung-zur-CCR
-- before Replik even though Replik is the infringement-side response
-- to the same triggering event.
--
-- New sequence_order assignment (preserves the soc=0, prelim=5,
-- sod=10, ccr=11 anchors at the head; phase markers interim/oral/
-- decision/cost_app/appeal_spawn keep their existing 40/50/60/70/80
-- slots at the tail):
--
-- Old → New submission_code track date
-- --- --- --------------- ----- ----
-- 0 0 upc.inf.cfi.soc — D+0
-- 5 5 upc.inf.cfi.prelim — D+1mo
-- 10 10 upc.inf.cfi.sod infringement D+3mo
-- 11 20 upc.inf.cfi.ccr revocation D+3mo
-- 20 12 upc.inf.cfi.reply infringement D+5mo ← MOVED UP
-- 12 22 upc.inf.cfi.def_to_ccr revocation D+5mo
-- 13 30 upc.inf.cfi.app_to_amend amendment D+5mo
-- 30 14 upc.inf.cfi.rejoin infringement D+6mo ← MOVED UP
-- 22 24 upc.inf.cfi.reply_def_ccr revocation D+7mo
-- 21 32 upc.inf.cfi.def_to_amend amendment D+7mo
-- 32 26 upc.inf.cfi.rejoin_reply_ccr revocation D+8mo
-- 31 34 upc.inf.cfi.reply_def_amd amendment D+8mo
-- 33 36 upc.inf.cfi.rejoin_amd amendment D+9mo
-- 40 40 upc.inf.cfi.interim phase later
-- 50 50 upc.inf.cfi.oral phase later
-- 60 60 upc.inf.cfi.decision phase later
-- 70 70 upc.inf.cfi.cost_app phase later
-- 80 80 upc.inf.cfi.appeal_spawn phase later
--
-- Order within each tied-date group after the reshuffle:
-- D+3mo: sod(10), ccr(20) — SoD then its CCR
-- D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
-- D+7mo: reply_def_ccr(24), def_to_amend(32) — rev → amd
-- D+8mo: rejoin_reply_ccr(26), reply_def_amd(34) — rev → amd
--
-- (no infringement-track rule at +7mo or +8mo so revocation leads
-- those dates; rejoin sits alone at +6mo so it has no peers to order
-- against.)
--
-- audit_reason set_config required at the top — the deadline_rules
-- audit trigger raises EXCEPTION 'audit reason required' on any
-- mutation without it (cf. mig 099 hotfix history).
--
-- Idempotency: every UPDATE is guarded by both the submission_code
-- AND the SOURCE sequence_order, so re-apply is a no-op once the new
-- numbers are in place.
SELECT set_config(
'paliad.audit_reason',
'mig 102: re-sequence upc.inf.cfi rules track-aware (infringement → revocation → amendment within tied-date groups; m''s 2026-05-18 ask, t-paliad-207 interactive session)',
true);
-- Two-phase swap to avoid sequence collisions during the UPDATE
-- (otherwise two rules can briefly share a sequence_order if Postgres
-- evaluates them in parallel). Phase 1: move every reshuffled rule to
-- a high temporary number (1000+). Phase 2: assign final numbers.
-- ─── Phase 1: park reshuffled rules at 1000+ ────────────────────────
UPDATE paliad.deadline_rules
SET sequence_order = 1011
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 11;
UPDATE paliad.deadline_rules
SET sequence_order = 1012
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 12;
UPDATE paliad.deadline_rules
SET sequence_order = 1013
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 13;
UPDATE paliad.deadline_rules
SET sequence_order = 1020
WHERE submission_code = 'upc.inf.cfi.reply'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 20;
UPDATE paliad.deadline_rules
SET sequence_order = 1021
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 21;
UPDATE paliad.deadline_rules
SET sequence_order = 1022
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 22;
UPDATE paliad.deadline_rules
SET sequence_order = 1030
WHERE submission_code = 'upc.inf.cfi.rejoin'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 30;
UPDATE paliad.deadline_rules
SET sequence_order = 1031
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 31;
UPDATE paliad.deadline_rules
SET sequence_order = 1032
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 32;
UPDATE paliad.deadline_rules
SET sequence_order = 1033
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 33;
-- ─── Phase 2: assign final track-aware numbers ──────────────────────
UPDATE paliad.deadline_rules
SET sequence_order = 12
WHERE submission_code = 'upc.inf.cfi.reply'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1020;
UPDATE paliad.deadline_rules
SET sequence_order = 14
WHERE submission_code = 'upc.inf.cfi.rejoin'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1030;
UPDATE paliad.deadline_rules
SET sequence_order = 20
WHERE submission_code = 'upc.inf.cfi.ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1011;
UPDATE paliad.deadline_rules
SET sequence_order = 22
WHERE submission_code = 'upc.inf.cfi.def_to_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1012;
UPDATE paliad.deadline_rules
SET sequence_order = 24
WHERE submission_code = 'upc.inf.cfi.reply_def_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1022;
UPDATE paliad.deadline_rules
SET sequence_order = 26
WHERE submission_code = 'upc.inf.cfi.rejoin_reply_ccr'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1032;
UPDATE paliad.deadline_rules
SET sequence_order = 30
WHERE submission_code = 'upc.inf.cfi.app_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1013;
UPDATE paliad.deadline_rules
SET sequence_order = 32
WHERE submission_code = 'upc.inf.cfi.def_to_amend'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1021;
UPDATE paliad.deadline_rules
SET sequence_order = 34
WHERE submission_code = 'upc.inf.cfi.reply_def_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1031;
UPDATE paliad.deadline_rules
SET sequence_order = 36
WHERE submission_code = 'upc.inf.cfi.rejoin_amd'
AND proceeding_type_id = 8
AND lifecycle_state = 'published'
AND sequence_order = 1033;

View File

@@ -281,7 +281,8 @@ func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
@@ -289,7 +290,7 @@ func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
row, err := dbSvc.approval.GetRequest(r.Context(), requestID)
row, err := dbSvc.approval.GetRequest(r.Context(), uid, requestID)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -359,7 +359,7 @@ func itoa(n int) string {
// POST /api/projects/{id}/counterclaim
//
// Body: {
// "proceeding_type_id": 9, // optional, defaults to UPC_REV
// "proceeding_type_id": 9, // optional, defaults to upc.rev.cfi
// "flip_our_side": false, // optional, default-flip otherwise
// "title": "EP3456789 — Widerklage (CCR)", // optional, auto-suggested
// "case_number": "ACT_xxx_2026" // optional CCR case number

View File

@@ -174,7 +174,7 @@ type Project struct {
// InstanceLevel is the procedural instance the project sits at:
// 'first' (default) | 'appeal' | 'cassation'. Combined with the
// proceeding code + jurisdiction by FristenrechnerService to pick
// the effective proceeding (DE_INF + appeal → DE_INF_OLG, etc.).
// the effective proceeding (de.inf.lg + appeal → de.inf.olg, etc.).
// NULL = unset / not applicable; the calculator treats NULL as
// 'first'. Backfill happens via the project-detail picker UI
// (Phase 3 Slice 8); this column ships in Slice 1 ahead of the
@@ -467,7 +467,7 @@ 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"`
Code *string `db:"code" json:"code,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"`
@@ -594,7 +594,9 @@ type DeadlineRuleAudit struct {
}
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
// management) or UPC_*/DE_*/EPA_*/EP_GRANT (Fristenrechner UI).
// 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"`

View File

@@ -809,16 +809,67 @@ func marshalJSONOrNull(m map[string]any) ([]byte, error) {
// ApprovalRequestView is the inbox-friendly projection of an approval
// request: the bare ApprovalRequest plus the contextual labels the inbox
// needs to render a row without further fetches.
//
// ViewerCanApprove + ViewerIsRequester are per-viewer eligibility flags
// computed against the $1 callerID bound at query time (t-paliad-202).
// The frontend uses them to grey out the action buttons it knows the
// server would reject, replacing the previous click-then-alert UX.
type ApprovalRequestView struct {
models.ApprovalRequest
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
}
// approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean
// expression that returns true iff the user bound to $1 is qualified to
// approve the approval_requests row aliased `ar` on the project aliased
// `p` (i.e. the SELECT must include `paliad.approval_requests ar JOIN
// paliad.projects p ON p.id = ar.project_id`). The three eligibility
// branches mirror canApprove (line 484):
//
// - $1 is global_admin, OR
// - $1 has direct/ancestor project_teams membership with responsibility
// ∈ {lead, member} AND a profession at or above the threshold
// (t-paliad-148 tuple-with-gate), OR
// - $1 has partner-unit-derived authority (t-paliad-139).
//
// Self-authorship is NOT subtracted here — callers add the
// `ar.requested_by <> $1` predicate when they want the strict
// "can approve" semantics (the inbox WHERE) or fold it into the
// SELECT (viewer_can_approve column). Keeping the two predicates
// separate lets the same fragment serve both ListPendingForApprover's
// filter and the per-row viewer flag without duplicating SQL.
const approvalEligibilitySQL = `(
EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
)
)`
// approvalRequestViewColumns binds $1 = callerID via the two viewer_*
// flags. Every caller must pass the caller's UUID as the first arg.
const approvalRequestViewColumns = `
ar.id, ar.project_id, ar.entity_type, ar.entity_id, ar.lifecycle_event,
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
@@ -832,7 +883,9 @@ const approvalRequestViewColumns = `
COALESCE(ru.display_name, ru.email) AS requester_name,
ru.email AS requester_email,
du.display_name AS decider_name,
du.email AS decider_email`
du.email AS decider_email,
(ar.status = 'pending' AND ar.requested_by <> $1 AND ` + approvalEligibilitySQL + `) AS viewer_can_approve,
(ar.requested_by = $1) AS viewer_is_requester`
const approvalRequestViewJoins = `
paliad.approval_requests ar
@@ -860,34 +913,10 @@ func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID u
conds := []string{
"ar.status = 'pending'",
"ar.requested_by <> $1",
// Eligibility (any one branch suffices):
// - caller is global_admin, OR
// - caller has direct/ancestor project_teams membership with
// responsibility ∈ {lead, member} AND profession at or above
// the threshold (t-paliad-148 tuple-with-gate), OR
// - caller is a partner-unit-derived member with derive_grants_authority=true
// on an attachment in the project's path, and the unit_role maps to a
// profession at or above the threshold (t-paliad-139).
`(EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`,
// Eligibility predicate (the three branches mirror canApprove and
// the viewer_can_approve SELECT expression — same fragment, single
// source of truth).
approvalEligibilitySQL,
}
args := []any{callerID}
if filter.ProjectID != nil {
@@ -946,13 +975,15 @@ func (s *ApprovalService) ListSubmittedByUser(ctx context.Context, callerID uuid
}
// GetRequest returns one approval request hydrated for the inbox detail
// view. Visibility is gated upstream by the handler (anyone with project
// access can see the request).
func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (*ApprovalRequestView, error) {
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $1`,
// view, with viewer_can_approve / viewer_is_requester resolved for
// callerID. Visibility is gated upstream by the handler (anyone with
// project access can see the request).
func (s *ApprovalService) GetRequest(ctx context.Context, callerID, requestID uuid.UUID) (*ApprovalRequestView, error) {
// $1 = callerID (binds the viewer_* flags); $2 = requestID.
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $2`,
approvalRequestViewColumns, approvalRequestViewJoins)
var v ApprovalRequestView
if err := s.db.GetContext(ctx, &v, q, requestID); err != nil {
if err := s.db.GetContext(ctx, &v, q, callerID, requestID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -974,26 +1005,7 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
JOIN paliad.projects p ON p.id = ar.project_id
WHERE ar.status = 'pending'
AND ar.requested_by <> $1
AND (EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`
AND ` + approvalEligibilitySQL
var n int
if err := s.db.GetContext(ctx, &n, q, callerID); err != nil {
return 0, fmt.Errorf("pending count: %w", err)

View File

@@ -812,3 +812,137 @@ func TestApprovalService_ListSubmittedByUser_PendingVisible(t *testing.T) {
t.Errorf("other user: len(rows) = %d, want 0 — must scope by requested_by", len(rows))
}
}
// TestApprovalService_ViewerFlags pins the per-viewer eligibility flags on
// ApprovalRequestView (t-paliad-202). Drives /inbox grey-out of
// Genehmigen/Ablehnen/Zurückziehen instead of click-then-error.
//
// Matrix (one pending request, four viewers):
//
// viewer viewer_can_approve viewer_is_requester
// requester (self) false true → only Zurückziehen
// approver (peer) true false → Genehmigen + Ablehnen
// other (no team) false false → all three disabled
// global_admin true false → Genehmigen + Ablehnen
func TestApprovalService_ViewerFlags(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
// Profession + global_role tuning: the live-DB seed gives every user
// global_role='standard' + profession=NULL, which means nobody is
// eligible by default. Promote requester→associate (matches threshold)
// and approver→partner (above threshold), and create a fourth user
// with global_role='global_admin' (the override branch).
if _, err := env.pool.ExecContext(ctx,
`UPDATE paliad.users SET profession = 'associate' WHERE id = $1`, env.requester); err != nil {
t.Fatalf("set requester profession: %v", err)
}
if _, err := env.pool.ExecContext(ctx,
`UPDATE paliad.users SET profession = 'partner' WHERE id = $1`, env.approver); err != nil {
t.Fatalf("set approver profession: %v", err)
}
adminID := uuid.New()
if _, err := env.pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
ON CONFLICT (id) DO NOTHING`, adminID); err != nil {
t.Logf("skip auth.users seed for admin: %v (continuing)", err)
}
if _, err := env.pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
VALUES ($1, $1::text || '@test.local', 'Admin', 'munich', 'global_admin')
ON CONFLICT (id) DO UPDATE SET global_role = 'global_admin'`, adminID); err != nil {
t.Fatalf("seed admin: %v", err)
}
defer func() {
ctx := context.Background()
env.pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, adminID)
env.pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, adminID)
}()
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
tx, err := env.pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
if err != nil {
tx.Rollback()
t.Fatalf("SubmitCreate: %v", err)
}
if reqID == nil {
tx.Rollback()
t.Fatal("SubmitCreate returned nil request id")
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
cases := []struct {
name string
viewer uuid.UUID
wantCanApprove bool
wantIsRequester bool
}{
{"self_authored", env.requester, false, true},
{"eligible_approver", env.approver, true, false},
{"non_eligible_viewer", env.other, false, false},
{"global_admin", adminID, true, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
row, err := env.approvals.GetRequest(ctx, c.viewer, *reqID)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if row == nil {
t.Fatal("GetRequest returned nil — request should exist")
}
if row.ViewerCanApprove != c.wantCanApprove {
t.Errorf("viewer_can_approve = %v, want %v",
row.ViewerCanApprove, c.wantCanApprove)
}
if row.ViewerIsRequester != c.wantIsRequester {
t.Errorf("viewer_is_requester = %v, want %v",
row.ViewerIsRequester, c.wantIsRequester)
}
})
}
// ListPendingForApprover stamps the same flags. The approver runs the
// query; they should see one row with viewer_can_approve=true,
// viewer_is_requester=false.
pending, err := env.approvals.ListPendingForApprover(ctx, env.approver, InboxFilter{})
if err != nil {
t.Fatalf("ListPendingForApprover: %v", err)
}
if len(pending) != 1 {
t.Fatalf("len(pending) = %d, want 1", len(pending))
}
if !pending[0].ViewerCanApprove {
t.Error("ListPendingForApprover: viewer_can_approve = false, want true")
}
if pending[0].ViewerIsRequester {
t.Error("ListPendingForApprover: viewer_is_requester = true, want false")
}
// ListSubmittedByUser carries them too. Requester runs the query; the
// one row must have viewer_can_approve=false (self-approval blocked)
// and viewer_is_requester=true.
mine, err := env.approvals.ListSubmittedByUser(ctx, env.requester, InboxFilter{})
if err != nil {
t.Fatalf("ListSubmittedByUser: %v", err)
}
if len(mine) != 1 {
t.Fatalf("len(mine) = %d, want 1", len(mine))
}
if mine[0].ViewerCanApprove {
t.Error("ListSubmittedByUser: viewer_can_approve = true on self-authored row, want false")
}
if !mine[0].ViewerIsRequester {
t.Error("ListSubmittedByUser: viewer_is_requester = false on self-authored row, want true")
}
}

View File

@@ -72,8 +72,8 @@ func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []mod
}
code := ""
if r.Code != nil {
code = *r.Code
if r.SubmissionCode != nil {
code = *r.SubmissionCode
}
results = append(results, CalculatedDeadline{

View File

@@ -120,7 +120,7 @@ func TestCalculateFromRules_BatchAndZeroDuration(t *testing.T) {
rules := []models.DeadlineRule{
{ID: uuid.New(), Name: "Filing", DurationValue: 0, DurationUnit: "months"},
{ID: uuid.New(), Name: "Defence", Code: ptr("inf.sod"), DurationValue: 3, DurationUnit: "months", Timing: ptr("after")},
{ID: uuid.New(), Name: "Defence", SubmissionCode: ptr("upc.inf.cfi.sod"), DurationValue: 3, DurationUnit: "months", Timing: ptr("after")},
}
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
results := calc.CalculateFromRules(in, rules, "DE", "UPC")
@@ -136,8 +136,8 @@ func TestCalculateFromRules_BatchAndZeroDuration(t *testing.T) {
if results[1].DueDate != "2026-04-13" {
t.Errorf("3-month rule: got %s, want 2026-04-13", results[1].DueDate)
}
if results[1].RuleCode != "inf.sod" {
t.Errorf("rule code: got %q, want inf.sod", results[1].RuleCode)
if results[1].RuleCode != "upc.inf.cfi.sod" {
t.Errorf("rule code: got %q, want upc.inf.cfi.sod", results[1].RuleCode)
}
}

View File

@@ -27,7 +27,7 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
// condition_flag, and condition_rule_id — they were superseded by
// priority / condition_expr / is_court_set in the unified Phase 3
// shape. The SELECT now reads only the live schema.
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type, duration_value,
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
alt_duration_value, alt_duration_unit, alt_rule_code,

View File

@@ -62,16 +62,16 @@ func (s *DeadlineSearchService) SetEventCategoryService(ec *EventCategoryService
//
// Empty bucket slug = no narrowing.
var ForumToProceedingCodes = map[string][]string{
"upc_cfi": {"UPC_INF", "UPC_REV", "UPC_PI", "UPC_DAMAGES", "UPC_DISCOVERY", "UPC_APP_ORDERS"},
"upc_coa": {"UPC_APP", "UPC_COST_APPEAL"},
"de_lg": {"DE_INF"},
"de_olg": {"DE_INF_OLG"},
"de_bgh": {"DE_INF_BGH", "DE_NULL_BGH", "DPMA_BGH_RB"},
"de_bpatg": {"DE_NULL", "DPMA_BPATG_BESCHWERDE"},
"epa_grant": {"EP_GRANT"},
"epa_opp": {"EPA_OPP"},
"epa_appeal": {"EPA_APP"},
"dpma": {"DPMA_OPP"},
"upc_cfi": {CodeUPCInfringement, CodeUPCRevocation, CodeUPCCounterclaim, CodeUPCPreliminary, CodeUPCDamages, CodeUPCDiscovery, CodeUPCAppealOrder},
"upc_coa": {CodeUPCAppealMerits, CodeUPCAppealCost},
"de_lg": {CodeDEInfringementLG},
"de_olg": {CodeDEInfringementOLG},
"de_bgh": {CodeDEInfringementBGH, CodeDENullityBGH, CodeDPMAAppealBGH},
"de_bpatg": {CodeDENullityBPatG, CodeDPMAAppealBPatG},
"epa_grant": {CodeEPAGrant},
"epa_opp": {CodeEPAOpposition},
"epa_appeal": {CodeEPAOppositionAppeal},
"dpma": {CodeDPMAOpposition},
}
// SearchOptions carries the optional facet filters from the URL query
@@ -870,6 +870,77 @@ func FormatLegalSourceDisplay(src string) string {
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).
//
// 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
}
// RefreshSearchView re-populates the materialised view. Safe to call on
// every server boot — it's a CONCURRENTLY refresh against a < 1k row
// view, well under 100 ms in practice. Called from cmd/server/main.go

View File

@@ -40,6 +40,38 @@ func TestFormatLegalSourceDisplay(t *testing.T) {
}
}
// TestBuildLegalSourceURL covers the structured-form → youpc.org/laws
// permalink mapping. Only the UPC corpus has a youpc home today;
// DE/EPA/EU bodies fall through to the empty string and the renderer
// shows display text without a link.
func TestBuildLegalSourceURL(t *testing.T) {
cases := []struct {
in, want string
}{
{"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.RoP.49.2.a", "https://youpc.org/laws#UPCRoP.049"},
{"UPC.RoP.19.1", "https://youpc.org/laws#UPCRoP.019"},
{"UPC.UPCA.83", "https://youpc.org/laws#UPCA.083"},
{"UPC.UPCS.40.1", "https://youpc.org/laws#UPCS.040"},
{"DE.PatG.82.1", ""},
{"DE.ZPO.276.1", ""},
{"EU.EPÜ.108", ""},
{"EU.EPC-R.79.1", ""},
{"EU.RPBA.12.1.c", ""},
{"UPC.RoP", ""},
{"", ""},
}
for _, c := range cases {
got := BuildLegalSourceURL(c.in)
if got != c.want {
t.Errorf("BuildLegalSourceURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestNormalizeQuery covers the input-side legal-prefix stripping that
// keeps "§ 82" / "Art. 108" findable against structured legal_source
// values that don't carry the prefix.
@@ -96,14 +128,15 @@ func TestDeadlineSearch(t *testing.T) {
}
card := findCardBySlug(t, resp, "statement-of-defence")
// Expected at minimum: UPC R.23, ZPO §276, PatG §82, EPC R.79, PatG §59.
// The actual data has 9 rule rows (UPC_INF, UPC_REV, UPC_PI,
// UPC_DAMAGES, UPC_DISCOVERY, DE_INF, DE_NULL, EPA_OPP, DPMA_OPP).
// The actual data has 9 rule rows (upc.inf.cfi, upc.rev.cfi,
// upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, de.inf.lg,
// de.null.bpatg, epa.opp.opd, dpma.opp.dpma).
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
mustHaveLegalSource(t, card, "DE.ZPO.276.1")
mustHaveLegalSource(t, card, "DE.PatG.82.1")
mustHaveLegalSource(t, card, "EU.EPC-R.79.1")
mustHaveLegalSource(t, card, "DE.PatG.59.3")
mustHaveProceedingCodes(t, card, "UPC_INF", "DE_INF", "DE_NULL", "EPA_OPP", "DPMA_OPP")
mustHaveProceedingCodes(t, card, CodeUPCInfringement, CodeDEInfringementLG, CodeDENullityBPatG, CodeEPAOpposition, CodeDPMAOpposition)
})
t.Run("RoP 23 returns the UPC R.23 hit", func(t *testing.T) {
@@ -169,7 +202,7 @@ func TestDeadlineSearch(t *testing.T) {
}
// Statement-of-defence is filed by the defendant. Filtering
// party=claimant should NOT drop the concept entirely — the
// effective_party can vary per pill (e.g. EPA_OPP Erwiderung
// effective_party can vary per pill (e.g. epa.opp.opd Erwiderung
// is owed by the patentee/claimant). At least it must not
// return any card with EVERY pill on defendant side.
for _, c := range resp.Cards {
@@ -254,9 +287,9 @@ func TestDeadlineSearch(t *testing.T) {
t.Fatalf("search: %v", err)
}
// Every rule pill must be a UPC proceeding. The seed maps every
// concept under this subtree to UPC_INF or UPC_APP — no DE/EPA/
// DPMA codes should leak.
allowedRulePrefix := []string{"UPC_"}
// concept under this subtree to upc.inf.cfi or upc.apl.merits — no
// DE/EPA/DPMA codes should leak.
allowedRulePrefix := []string{"upc."}
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.Kind != "rule" {
@@ -289,21 +322,21 @@ func TestDeadlineSearch(t *testing.T) {
if err != nil {
t.Fatalf("search: %v", err)
}
// Junction maps three concepts × UPC_INF for this leaf:
// Junction maps three concepts × upc.inf.cfi for this leaf:
// defence-to-counterclaim-for-revocation, application-to-amend,
// reply-to-defence. Every pill must be UPC_INF.
// reply-to-defence. Every pill must be upc.inf.cfi.
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.Kind != "rule" {
continue
}
if p.Proceeding == nil || p.Proceeding.Code != "UPC_INF" {
if p.Proceeding == nil || p.Proceeding.Code != CodeUPCInfringement {
code := "(nil)"
if p.Proceeding != nil {
code = p.Proceeding.Code
}
t.Errorf("klageerwiderung-mit-ccr leaf leaked non-UPC_INF pill on %q: proc=%s",
c.Concept.Slug, code)
t.Errorf("klageerwiderung-mit-ccr leaf leaked non-%s pill on %q: proc=%s",
CodeUPCInfringement, c.Concept.Slug, code)
}
}
}
@@ -344,8 +377,8 @@ func TestDeadlineSearch(t *testing.T) {
})
t.Run("v4 forum filter ANDs against subtree narrowing", func(t *testing.T) {
// Pick the UPC_INF subtree and add a forum chip that excludes
// UPC_INF — the result must be empty (the user contradicted
// Pick the upc.inf.cfi subtree and add a forum chip that excludes
// upc.inf.cfi — the result must be empty (the user contradicted
// themselves; empty is the correct UX).
resp, err := svc.Search(ctx, "", SearchOptions{
EventCategorySlug: "cms-eingang.gegenseite.upc-inf",

View File

@@ -238,8 +238,8 @@ func (s *EventCategoryService) ConceptIDsForSlug(ctx context.Context, slug strin
//
// Distinct from "every concept_id ever mapped" because a concept can
// appear at the root view in MULTIPLE proceeding contexts that the tree
// authors intentionally surfaced — e.g. opposition under both EPA_OPP
// and DPMA_OPP. We respect those tuples even at the root so the
// authors intentionally surfaced — e.g. opposition under both epa.opp.opd
// and dpma.opp.dpma. We respect those tuples even at the root so the
// result-card pill set matches the junction's design.
func (s *EventCategoryService) AllOutcomes(ctx context.Context) ([]ConceptOutcome, error) {
const sqlText = `

View File

@@ -166,8 +166,8 @@ func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInp
WasAdjusted: wasAdj,
AdjustmentReason: reason,
}
if r.Code != nil {
d.Code = *r.Code
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty

View File

@@ -54,6 +54,15 @@ type UIDeadline struct {
Priority string `json:"priority"`
RuleRef string `json:"ruleRef"`
LegalSource string `json:"legalSource,omitempty"`
// LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)")
// of LegalSource, produced by FormatLegalSourceDisplay. Frontend
// renders this in the deadline card meta line; falls back to
// RuleRef when LegalSource is empty.
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
// LegalSourceURL is the youpc.org/laws permalink when the cited
// body is hosted there (UPCRoP / UPCA / UPCS today). Empty for
// DE/EPA/EU bodies — the renderer shows display text without a link.
LegalSourceURL string `json:"legalSourceURL,omitempty"`
Notes string `json:"notes,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
DueDate string `json:"dueDate"`
@@ -98,7 +107,7 @@ var ErrUnknownProceedingType = errors.New("unknown proceeding type")
// empty/nil for the legacy behaviour.
//
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with anchor_alt =
// 'priority_date' (e.g. EP_GRANT.ep_grant.publish per Art. 93 EPÜ) use
// '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",
@@ -158,13 +167,13 @@ type CalcOptions struct {
// Audit-driven extensions:
//
// - opts.Flags can flip flag-conditioned rules onto their alt_* values
// (e.g. UPC_INF inf.reply / inf.rejoin under "with_ccr"). When a
// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr"). When a
// rule's condition_flag array is non-empty, the rule renders iff
// EVERY element is in opts.Flags; rules that fail this gate are
// suppressed entirely (used by Phase B1 cross-flow rules that should
// only appear with their flag).
// - opts.PriorityDateStr overrides the anchor for rules with anchor_alt
// set (e.g. EP_GRANT publication date is 18mo from priority, not filing).
// set (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. Used for court-extended deadlines and for entering
@@ -272,8 +281,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
}
if r.Code != nil {
d.Code = *r.Code
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty
@@ -283,6 +292,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
}
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
@@ -300,8 +311,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
if r.ParentID != nil && courtSet[*r.ParentID] {
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if _, ok := overrideDates[*prev.Code]; ok {
if prev.SubmissionCode != nil {
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
parentOverridden = true
}
}
@@ -318,7 +329,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// 3. parent set, court-determined → IsCourtSet (waypoint)
// 4. parent set, NOT court-determined → "filed-with-parent"
// semantic: rule is filed AT THE SAME TIME as its parent
// (e.g. UPC_REV.rev.app_to_amend, rev.cc_inf — R.49(2) says
// (e.g. upc.rev.cfi.rev.app_to_amend, rev.cc_inf — R.49(2) says
// Application to amend / Counterclaim for infringement are
// INCLUDED in the Defence to revocation). Use the parent's
// computed date.
@@ -328,12 +339,12 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// court-set placeholder and the parent-inheritance.
if r.DurationValue == 0 {
// User override always wins.
if r.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
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.Code] = ov
computed[*r.SubmissionCode] = ov
deadlines = append(deadlines, d)
continue
}
@@ -344,8 +355,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.IsRootEvent = true
d.DueDate = triggerDateStr
d.OriginalDate = triggerDateStr
if r.Code != nil {
computed[*r.Code] = triggerDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = triggerDate
}
} else if r.ParentID != nil && !r.IsCourtSet {
// Bucket 4: filed-with-parent. Inherit parent's date.
@@ -365,11 +376,11 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
var haveParentDate bool
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if ov, ok := overrideDates[*prev.Code]; ok {
if prev.SubmissionCode != nil {
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
parentDate = ov
haveParentDate = true
} else if ref, ok := computed[*prev.Code]; ok {
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
parentDate = ref
haveParentDate = true
}
@@ -380,8 +391,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
if haveParentDate {
d.DueDate = parentDate.Format("2006-01-02")
d.OriginalDate = d.DueDate
if r.Code != nil {
computed[*r.Code] = parentDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = parentDate
}
} else {
// Parent not yet computed (defensive — shouldn't
@@ -432,7 +443,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
continue
}
// Anchor: prefer alt-anchor (e.g. priority_date for EP_GRANT publish)
// 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
@@ -442,14 +453,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// Linear scan is fine — rule trees are < 20 entries.
for _, prev := range rules {
if prev.ID == *r.ParentID {
if prev.Code != nil {
if prev.SubmissionCode != nil {
// User override on the parent rule wins over
// the calculated date — lets the user redirect
// downstream from a real (court-extended,
// court-set) date.
if ov, ok := overrideDates[*prev.Code]; ok {
if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
baseDate = ov
} else if ref, ok := computed[*prev.Code]; ok {
} else if ref, ok := computed[*prev.SubmissionCode]; ok {
baseDate = ref
}
}
@@ -484,14 +495,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// the user's date. Skip holiday rollover — the user's date is
// authoritative. Downstream rules that chain off this rule will
// see the override via the parent-anchor lookup above.
if r.Code != nil {
if ov, ok := overrideDates[*r.Code]; ok {
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.Code] = ov
computed[*r.SubmissionCode] = ov
deadlines = append(deadlines, d)
continue
}
@@ -527,8 +538,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
d.DueDate = adjusted.Format("2006-01-02")
d.WasAdjusted = wasAdj
d.AdjustmentReason = reason
if r.Code != nil {
computed[*r.Code] = adjusted
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = adjusted
}
deadlines = append(deadlines, d)
}
@@ -599,6 +610,7 @@ type RuleCalculationRule struct {
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"`
@@ -661,8 +673,8 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
},
TriggerDate: params.TriggerDate,
}
if rule.Code != nil {
out.Rule.LocalCode = *rule.Code
if rule.SubmissionCode != nil {
out.Rule.LocalCode = *rule.SubmissionCode
}
if rule.RuleCode != nil {
out.Rule.RuleRef = *rule.RuleCode
@@ -670,6 +682,7 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
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
@@ -715,7 +728,7 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
}
// Zero-duration non-court-determined rules are "filed at the same
// time as parent" markers (UPC_REV.app_to_amend, UPC_REV.cc_inf):
// time as parent" markers (upc.rev.cfi.app_to_amend, upc.rev.cfi.cc_inf):
// effectively mean "due on the trigger date itself". The card-click
// flow doesn't need to surface those as a calc panel — but if it
// does, returning the trigger date is the right answer.
@@ -797,7 +810,7 @@ func (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRule
err = s.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND code = $2 AND is_active = true`,
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
pt.ID, params.RuleLocalCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrUnknownRule
@@ -1206,8 +1219,8 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
WasAdjusted: wasAdj,
AdjustmentReason: reason,
}
if r.Code != nil {
d.Code = *r.Code
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
}
if r.PrimaryParty != nil {
d.Party = *r.PrimaryParty
@@ -1217,6 +1230,8 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
}
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

View File

@@ -46,7 +46,7 @@ func TestAllFlagsSet(t *testing.T) {
{"single flag, present → true (legacy with_ccr pattern)", []string{"with_ccr"}, mkSet("with_ccr"), true},
{"single flag, absent → false", []string{"with_ccr"}, mkSet(), false},
{"single flag, other present → false", []string{"with_ccr"}, mkSet("with_amend"), false},
{"two flags, both present → true (UPC_INF nested)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend"), true},
{"two flags, both present → true (upc.inf.cfi nested)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend"), true},
{"two flags, only one present → false", []string{"with_ccr", "with_amend"}, mkSet("with_ccr"), false},
{"two flags, both present + extra → true (extra flags don't matter)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend", "with_cci"), true},
}
@@ -86,18 +86,18 @@ func TestCalculateRule(t *testing.T) {
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
t.Run("plain rule calc — UPC_INF inf.sod, R.23(1), 3 months", func(t *testing.T) {
t.Run("plain rule calc — upc.inf.cfi.sod, R.23(1), 3 months", func(t *testing.T) {
// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.sod",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.sod",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if got.IsCourtSet {
t.Errorf("inf.sod is not court-set; got IsCourtSet=true")
t.Errorf("upc.inf.cfi.sod is not court-set; got IsCourtSet=true")
}
if got.DueDate != "2026-04-15" {
t.Errorf("dueDate = %q, want 2026-04-15", got.DueDate)
@@ -105,22 +105,22 @@ func TestCalculateRule(t *testing.T) {
if got.Rule.LegalSourceDisplay != "UPC RoP R.23(1)" {
t.Errorf("legalSourceDisplay = %q, want UPC RoP R.23(1)", got.Rule.LegalSourceDisplay)
}
if got.Proceeding.Code != "UPC_INF" {
t.Errorf("proceeding code = %q, want UPC_INF", got.Proceeding.Code)
if got.Proceeding.Code != CodeUPCInfringement {
t.Errorf("proceeding code = %q, want upc.inf.cfi", got.Proceeding.Code)
}
})
t.Run("court-determined rule → IsCourtSet=true, no dueDate", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.decision",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.decision",
TriggerDate: "2026-01-15",
})
if err != nil {
t.Fatalf("CalculateRule: %v", err)
}
if !got.IsCourtSet {
t.Errorf("inf.decision should be court-set; got IsCourtSet=false")
t.Errorf("upc.inf.cfi.decision should be court-set; got IsCourtSet=false")
}
if got.DueDate != "" {
t.Errorf("court-set dueDate = %q, want empty", got.DueDate)
@@ -128,11 +128,12 @@ func TestCalculateRule(t *testing.T) {
})
t.Run("flag-conditional rule surfaces FlagsRequired even when not satisfied", func(t *testing.T) {
// inf.def_to_ccr requires with_ccr. Without the flag, FlagsRequired
// is still surfaced so the UI can render the checkbox.
// upc.inf.cfi.def_to_ccr requires with_ccr. Without the flag,
// FlagsRequired is still surfaced so the UI can render the
// checkbox.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.def_to_ccr",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.def_to_ccr",
TriggerDate: "2026-01-15",
})
if err != nil {
@@ -148,8 +149,8 @@ func TestCalculateRule(t *testing.T) {
t.Run("flag-conditional rule with flag → FlagsApplied populated", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.def_to_ccr",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.def_to_ccr",
TriggerDate: "2026-01-15",
Flags: []string{"with_ccr"},
})
@@ -163,8 +164,8 @@ func TestCalculateRule(t *testing.T) {
t.Run("missing TriggerDate → error", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
RuleLocalCode: "inf.sod",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "upc.inf.cfi.sod",
TriggerDate: "",
})
if err == nil {
@@ -174,7 +175,7 @@ func TestCalculateRule(t *testing.T) {
t.Run("unknown rule → ErrUnknownRule", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "totally.fake",
TriggerDate: "2026-01-15",
})
@@ -417,12 +418,12 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, "UPC_INF", "2026-01-15", CalcOptions{})
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-01-15", CalcOptions{})
if err != nil {
t.Fatalf("Calculate UPC_INF: %v", err)
t.Fatalf("Calculate upc.inf.cfi: %v", err)
}
if len(resp.Deadlines) == 0 {
t.Fatal("Calculate UPC_INF returned no deadlines — seed-data missing?")
t.Fatal("Calculate upc.inf.cfi returned no deadlines — seed-data missing?")
}
allowed := map[string]bool{
@@ -446,6 +447,6 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
}
}
if !sawConditionExpr {
t.Logf("warning: no UPC_INF rule had conditionExpr populated — verify mig 084 ran")
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
}
}

View File

@@ -0,0 +1,121 @@
package services
import (
"context"
"os"
"regexp"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// shapeRegex is the lowercase dot-separated form ratified by t-paliad-204
// and enforced at the DB layer by mig 096's paliad_proceeding_code_shape
// CHECK constraint. Every active fristenrechner-category row must match.
var shapeRegex = regexp.MustCompile(`^[a-z]+\.[a-z]+\.[a-z]+$`)
// TestProceedingCodeShape walks every active fristenrechner-category row
// in paliad.proceeding_types and asserts the `code` matches the
// taxonomy regex. Catches future inserts that slip past the CHECK
// constraint (e.g. via a manual psql edit on a staging snapshot) and
// catches drift between this Go layer's stable code constants and the
// DB.
//
// Mirrors the assertions in mig 096 §8 — same regex, same shape — so a
// failure here pinpoints which row went off-shape without making a DB
// trip first.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the pattern in
// project_service_test.go.
func TestProceedingCodeShape(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
var rows []struct {
ID int `db:"id"`
Code string `db:"code"`
}
if err := pool.SelectContext(ctx, &rows,
`SELECT id, code FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND is_active = true
ORDER BY id`); err != nil {
t.Fatalf("load active fristenrechner rows: %v", err)
}
if len(rows) == 0 {
t.Fatal("no active fristenrechner rows — mig 096 likely not applied")
}
for _, r := range rows {
if !shapeRegex.MatchString(r.Code) {
t.Errorf("proceeding_types[id=%d] code=%q does not match taxonomy shape %s",
r.ID, r.Code, shapeRegex.String())
}
}
// Spot-check the stable code constants in proceeding_mapping.go all
// resolve to live rows. Catches a constant being renamed without a
// matching mig update.
stable := []string{
CodeUPCInfringement, CodeUPCRevocation, CodeUPCCounterclaim,
CodeUPCPreliminary, CodeUPCDamages, CodeUPCDiscovery,
CodeUPCAppealMerits, CodeUPCAppealOrder, CodeUPCAppealCost,
CodeDEInfringementLG, CodeDEInfringementOLG, CodeDEInfringementBGH,
CodeDENullityBPatG, CodeDENullityBGH,
CodeEPAGrant, CodeEPAOpposition, CodeEPAOppositionAppeal,
CodeDPMAOpposition, CodeDPMAAppealBPatG, CodeDPMAAppealBGH,
}
for _, c := range stable {
var hit int
if err := pool.GetContext(ctx, &hit,
`SELECT count(*) FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, c); err != nil {
t.Fatalf("count rows for %s: %v", c, err)
}
if hit != 1 {
t.Errorf("stable code constant %q matches %d active rows, want 1", c, hit)
}
}
}
// TestProceedingCodeShapeRegexStandalone exercises the regex without
// hitting the DB so the shape rule is verified on every `go test ./...`
// run (no skip when TEST_DATABASE_URL is unset).
func TestProceedingCodeShapeRegexStandalone(t *testing.T) {
good := []string{
"upc.inf.cfi", "upc.rev.cfi", "upc.ccr.cfi", "upc.apl.merits",
"upc.apl.order", "upc.apl.cost", "de.inf.lg", "de.null.bgh",
"epa.opp.opd", "epa.grant.exa", "dpma.opp.dpma",
}
for _, code := range good {
if !shapeRegex.MatchString(code) {
t.Errorf("good code %q rejected by shape regex", code)
}
}
bad := []string{
"UPC_INF", // old uppercase
"upc.inf", // missing third position
"upc.inf.cfi.extra", // four positions
"upc..cfi", // empty middle
"upc-inf-cfi", // dashes
"_archived_litigation",
}
for _, code := range bad {
if shapeRegex.MatchString(code) {
t.Errorf("bad code %q accepted by shape regex", code)
}
}
}

View File

@@ -3,19 +3,49 @@ 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
// / DE_INF / EPA_OPP / …) 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
// + 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. **Never silent FK promotion**:
// every ambiguous case returns ok=false so callers can degrade
// gracefully ("no narrowing") instead of guessing.
// 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.
@@ -27,61 +57,83 @@ package services
// 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 + with_ccr,
// AMD+UPC → UPC_INF + with_amend). An empty slice means no flag
// 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 "UPC_INF", nil, true
return CodeUPCInfringement, nil, true
case "DE":
return "DE_INF", nil, true
return CodeDEInfringementLG, nil, true
}
case "REV":
switch jurisdiction {
case "UPC":
return "UPC_REV", nil, true
return CodeUPCRevocation, nil, true
case "DE":
return "DE_NULL", nil, true
return CodeDENullityBPatG, nil, true
}
case "CCR":
// Counterclaim revocation — UPC fold-in is structural (the
// counterclaim lives inside an UPC_INF proceeding with 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 "UPC_INF", []string{"with_ccr"}, true
return CodeUPCInfringement, []string{"with_ccr"}, true
case "DE":
return "DE_NULL", nil, true
return CodeDENullityBPatG, nil, true
}
case "AMD":
// Amendment-application bundled into UPC_INF via with_amend.
// Amendment-application bundled into upc.inf.cfi via with_amend.
// No DE / EPA / DPMA analogue today.
if jurisdiction == "UPC" {
return "UPC_INF", []string{"with_amend"}, true
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.
// disambiguate. UPC is unambiguous — upc.apl.merits covers
// the merits appeal track for inf/rev/ccr/damages.
if jurisdiction == "UPC" {
return "UPC_APP", nil, true
return CodeUPCAppealMerits, nil, true
}
case "APM":
// Preliminary injunction / urgency procedure — UPC-only
// concept in the fristenrechner taxonomy.
if jurisdiction == "UPC" {
return "UPC_PI", nil, true
return CodeUPCPreliminary, nil, true
}
case "OPP":
// Opposition — primarily EPA. DPMA has DPMA_OPP but it
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
// doesn't surface from the litigation vocabulary today.
if jurisdiction == "EPA" {
return "EPA_OPP", nil, true
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 code == CodeUPCCounterclaim {
return CodeUPCInfringement, []string{"with_ccr"}, true
}
return code, nil, false
}

View File

@@ -14,20 +14,20 @@ func TestMapLitigationToFristenrechner(t *testing.T) {
}
cases := []tc{
// Unambiguous UPC fold-ins.
{"INF", "UPC", "UPC_INF", nil, true},
{"REV", "UPC", "UPC_REV", nil, true},
{"APP", "UPC", "UPC_APP", nil, true},
{"APM", "UPC", "UPC_PI", nil, true},
// CCR + UPC = UPC_INF with the with_ccr flag.
{"CCR", "UPC", "UPC_INF", []string{"with_ccr"}, true},
// AMD + UPC = UPC_INF with the with_amend flag.
{"AMD", "UPC", "UPC_INF", []string{"with_amend"}, true},
{"INF", "UPC", CodeUPCInfringement, nil, true},
{"REV", "UPC", CodeUPCRevocation, nil, true},
{"APP", "UPC", CodeUPCAppealMerits, nil, true},
{"APM", "UPC", CodeUPCPreliminary, nil, true},
// CCR + UPC = upc.inf.cfi with the with_ccr flag.
{"CCR", "UPC", CodeUPCInfringement, []string{"with_ccr"}, true},
// AMD + UPC = upc.inf.cfi with the with_amend flag.
{"AMD", "UPC", CodeUPCInfringement, []string{"with_amend"}, true},
// DE first-instance / Nichtigkeit mappings.
{"INF", "DE", "DE_INF", nil, true},
{"REV", "DE", "DE_NULL", nil, true},
{"CCR", "DE", "DE_NULL", nil, true},
{"INF", "DE", CodeDEInfringementLG, nil, true},
{"REV", "DE", CodeDENullityBPatG, nil, true},
{"CCR", "DE", CodeDENullityBPatG, nil, true},
// EPA opposition.
{"OPP", "EPA", "EPA_OPP", nil, true},
{"OPP", "EPA", CodeEPAOpposition, nil, true},
// Ambiguous: APP+DE has both OLG and BGH analogues; project
// model can't disambiguate, so degrade.
{"APP", "DE", "", nil, false},
@@ -52,3 +52,32 @@ func TestMapLitigationToFristenrechner(t *testing.T) {
}
}
}
func TestResolveCounterclaimRouting(t *testing.T) {
t.Run("upc.ccr.cfi routes to upc.inf.cfi with with_ccr", func(t *testing.T) {
gotCode, gotFlags, routed := ResolveCounterclaimRouting(CodeUPCCounterclaim)
if gotCode != CodeUPCInfringement {
t.Errorf("effective code = %q, want %q", gotCode, CodeUPCInfringement)
}
if !reflect.DeepEqual(gotFlags, []string{"with_ccr"}) {
t.Errorf("default flags = %v, want [with_ccr]", gotFlags)
}
if !routed {
t.Errorf("routed = false, want true")
}
})
t.Run("non-ccr code passes through unchanged", func(t *testing.T) {
for _, code := range []string{CodeUPCInfringement, CodeUPCRevocation, CodeDEInfringementLG, "anything-else"} {
gotCode, gotFlags, routed := ResolveCounterclaimRouting(code)
if gotCode != code {
t.Errorf("ResolveCounterclaimRouting(%q) returned %q, want pass-through", code, gotCode)
}
if gotFlags != nil {
t.Errorf("ResolveCounterclaimRouting(%q) flags = %v, want nil", code, gotFlags)
}
if routed {
t.Errorf("ResolveCounterclaimRouting(%q) routed = true, want false", code)
}
}
})
}

View File

@@ -134,8 +134,8 @@ type CreateProjectInput struct {
// 'first' (default once the picker UI lands) | 'appeal' | 'cassation'.
// NULL = unset. Phase 3 Slice 8 (t-paliad-189, design §7) — the
// SmartTimeline + calculator combine this with proceeding_code +
// jurisdiction to pick the effective rule corpus (DE_INF + appeal →
// DE_INF_OLG, etc.). Validated against the mig 080 CHECK on the
// jurisdiction to pick the effective rule corpus (de.inf.lg + appeal →
// de.inf.olg, etc.). Validated against the mig 080 CHECK on the
// column; service surfaces ErrInvalidInput on a bad value.
InstanceLevel *string `json:"instance_level,omitempty"`
@@ -849,8 +849,15 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
id := uuid.New()
now := time.Now().UTC()
// path is NOT NULL but the trigger populates it; supply a placeholder
// the trigger will overwrite. (BEFORE INSERT trigger rewrites path.)
// path is NOT NULL but paliad.projects_sync_path() (BEFORE INSERT
// trigger from mig 018/021) overwrites it from id and parent path,
// so any non-null value satisfies the constraint. Use a literal
// placeholder rather than re-referencing $1 — reusing a parameter
// across columns with different SQL types (id is uuid, path is text)
// makes Postgres's planner reject the statement with 42P08
// "inconsistent types deduced for parameter" once the driver hands
// $1 across as an inferred type. The literal keeps the param list
// decoupled from the id column's type.
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
@@ -868,7 +875,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id, our_side, counterclaim_of,
instance_level, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
VALUES ($1, $2, $3, '', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, '{}'::jsonb, $24, $24)`,
id, input.Type, input.ParentID,
input.Title, input.Reference, input.Description, status,
@@ -1171,7 +1178,7 @@ func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error
}
// CounterclaimOpts narrows CreateCounterclaim. Empty zero values fall back
// to the design defaults: proceeding_type_id = UPC_REV, our_side = inverted
// to the design defaults: proceeding_type_id = upc.rev.cfi, our_side = inverted
// from the parent, title = "<patent reference> — Widerklage (CCR)" when a
// patent reference is resolvable, else "<parent title> — Widerklage".
//
@@ -1222,7 +1229,7 @@ func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, us
// and "both" pass through unchanged. The opts.FlipOurSide override
// supports the rare R.49.2.b CCI shape where flipping is wrong.
//
// proceeding_type_id default (§4.4): UPC_REV for the standard CCR-on-
// proceeding_type_id default (§4.4): upc.rev.cfi for the standard CCR-on-
// validity. UPC_CCI is the rarer R.49.2.b path; callers pass the id
// explicitly when they want it.
func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentID uuid.UUID, opts CounterclaimOpts) (*models.Project, error) {
@@ -1241,7 +1248,7 @@ func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentI
return nil, fmt.Errorf("%w: parent project is itself a counterclaim — two-level CCR chains are not allowed", ErrInvalidInput)
}
// Resolve proceeding_type_id default to UPC_REV when caller didn't
// Resolve proceeding_type_id default to upc.rev.cfi when caller didn't
// override. The DB row is required because the projection layer
// dereferences it (paliad.proceeding_types.code).
procTypeID := 0
@@ -1250,9 +1257,9 @@ func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentI
} else {
err := s.db.GetContext(ctx, &procTypeID,
`SELECT id FROM paliad.proceeding_types
WHERE code = 'UPC_REV' AND is_active = true`)
WHERE code = $1 AND is_active = true`, CodeUPCRevocation)
if err != nil {
return nil, fmt.Errorf("resolve default UPC_REV proceeding type: %w", err)
return nil, fmt.Errorf("resolve default %s proceeding type: %w", CodeUPCRevocation, err)
}
}
@@ -1281,12 +1288,15 @@ func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentI
id := uuid.New()
now := time.Now().UTC()
// path placeholder is overwritten by paliad.projects_sync_path();
// same rationale as ProjectService.Create — see comment there for
// why we use a literal '' instead of re-referencing $1.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
court, case_number, proceeding_type_id, our_side, counterclaim_of,
metadata, created_at, updated_at)
VALUES ($1, 'case', $2, $1::text, $3, 'active', $4,
VALUES ($1, 'case', $2, '', $3, 'active', $4,
$5, $6, $7, $8, $9, '{}'::jsonb, $10, $10)`,
id, childParentID, title, userID,
parent.Court, opts.CaseNumber, procTypeID,
@@ -1910,8 +1920,8 @@ func validateOurSide(s string) error {
// validateInstanceLevel checks the procedural-instance enum (Phase 3
// Slice 8, t-paliad-189, design §7). Empty string clears the column;
// the three named values map to the rule-corpus ladder DE_INF
// DE_INF_OLG → DE_INF_BGH that the SmartTimeline will surface in a
// the three named values map to the rule-corpus ladder de.inf.lg
// de.inf.olg → de.inf.bgh that the SmartTimeline will surface in a
// follow-up calculator slice. The DB-level CHECK on mig 080 enforces
// the same set; this validation gives a clearer error than letting
// the trigger fire.

View File

@@ -21,14 +21,21 @@ import (
// non-fristenrechner-category proceeding_types row.
//
// 2. ProjectService.Create returns ErrInvalidProceedingTypeCategory
// when handed a litigation-category id. The server-side service
// guard fires BEFORE the DB write hits the trigger from mig 088.
// when handed a non-fristenrechner-category id. The server-side
// service guard fires BEFORE the DB write hits the trigger from
// mig 088.
//
// 3. The mig 088 trigger rejects a raw INSERT that bypasses the Go
// service layer (defence-in-depth). A litigation-category id
// INSERT via plain SQL must raise EXCEPTION.
// service layer (defence-in-depth). A non-fristenrechner-category
// id INSERT via plain SQL must raise EXCEPTION.
//
// 4. Passing a fristenrechner-category id (UPC_INF) succeeds.
// 4. Passing a fristenrechner-category id (upc.inf.cfi) succeeds.
//
// Phase 3 Slice 9 follow-up B (t-paliad-200, mig 093) retired the
// 'litigation' category from the rule corpus; the negative-case lookup
// is now any non-fristenrechner-category row (the _archived_litigation
// pt mig 093 introduces is the canonical one and exists on every
// post-093 deploy).
//
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
@@ -63,20 +70,29 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
}
// -----------------------------------------------------------------
// 2 + 4. ProjectService.Create guard — typed error on litigation id,
// success on fristenrechner id.
// 2 + 4. ProjectService.Create guard — typed error on non-
// fristenrechner id, success on fristenrechner id.
//
// Pre-mig-093 this looked up category='litigation' AND code='INF';
// mig 093 retired the litigation category so the negative case now
// pulls any non-fristenrechner row (the _archived_litigation pt is
// the canonical post-093 row, but the query is broad in case other
// non-fristenrechner buckets are introduced).
// -----------------------------------------------------------------
var litigationID int
if err := pool.GetContext(ctx, &litigationID,
var nonFristenrechnerID int
if err := pool.GetContext(ctx, &nonFristenrechnerID,
`SELECT id FROM paliad.proceeding_types
WHERE category = 'litigation' AND code = 'INF' AND is_active = true`); err != nil {
t.Fatalf("look up INF id: %v", err)
WHERE category <> 'fristenrechner'
ORDER BY id
LIMIT 1`); err != nil {
t.Fatalf("look up non-fristenrechner id: %v", err)
}
var fristenrechnerID int
if err := pool.GetContext(ctx, &fristenrechnerID,
`SELECT id FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND code = 'UPC_INF' AND is_active = true`); err != nil {
t.Fatalf("look up UPC_INF id: %v", err)
WHERE category = 'fristenrechner' AND code = $1 AND is_active = true`,
CodeUPCInfringement); err != nil {
t.Fatalf("look up %s id: %v", CodeUPCInfringement, err)
}
users := NewUserService(pool)
@@ -104,14 +120,14 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
t.Fatalf("seed paliad.users: %v", err)
}
// 2. Litigation-category id → ErrInvalidProceedingTypeCategory.
// 2. Non-fristenrechner-category id → ErrInvalidProceedingTypeCategory.
_, err = svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeProject,
Title: "Slice 5 — litigation-id reject",
ProceedingTypeID: &litigationID,
Title: "Slice 5 — non-fristenrechner-id reject",
ProceedingTypeID: &nonFristenrechnerID,
})
if err == nil {
t.Error("Create with litigation-category proceeding_type_id should fail, but succeeded")
t.Error("Create with non-fristenrechner-category proceeding_type_id should fail, but succeeded")
} else if !errors.Is(err, ErrInvalidProceedingTypeCategory) {
t.Errorf("expected ErrInvalidProceedingTypeCategory, got %v", err)
}
@@ -141,9 +157,9 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
proceeding_type_id, metadata, created_at, updated_at)
VALUES ($1, 'project', NULL, $1::text, 'Slice 5 — trigger bypass', 'active', $2,
$3, '{}'::jsonb, now(), now())`,
rawID, userID, litigationID)
rawID, userID, nonFristenrechnerID)
if err == nil {
t.Error("raw INSERT with litigation-category proceeding_type_id should have raised; got nil")
t.Error("raw INSERT with non-fristenrechner-category proceeding_type_id should have raised; got nil")
}
}

View File

@@ -183,10 +183,10 @@ func TestRuleNameInLang(t *testing.T) {
func TestPredecessorMissingError(t *testing.T) {
pme := &PredecessorMissingError{
MissingRuleCode: "inf.soc",
MissingRuleCode: "upc.inf.cfi.soc",
MissingRuleNameDE: "Klageschrift",
MissingRuleNameEN: "Statement of Claim",
RequestedRuleCode: "inf.sod",
RequestedRuleCode: "upc.inf.cfi.sod",
RequestedRuleNameDE: "Klageerwiderung",
RequestedRuleNameEN: "Statement of Defence",
}
@@ -233,14 +233,14 @@ func TestAnnotateDependsOn(t *testing.T) {
socID := uuid.New()
sodID := uuid.New()
replyID := uuid.New()
socCode := "inf.soc"
sodCode := "inf.sod"
replyCode := "inf.reply"
socCode := "upc.inf.cfi.soc"
sodCode := "upc.inf.cfi.sod"
replyCode := "upc.inf.cfi.reply"
rules := []models.DeadlineRule{
{ID: socID, Code: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
{ID: sodID, ParentID: &socID, Code: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
{ID: replyID, ParentID: &sodID, Code: &replyCode, Name: "Replik", NameEN: "Reply"},
{ID: socID, SubmissionCode: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
{ID: sodID, ParentID: &socID, SubmissionCode: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
{ID: replyID, ParentID: &sodID, SubmissionCode: &replyCode, Name: "Replik", NameEN: "Reply"},
}
socDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)

View File

@@ -46,18 +46,20 @@ func TestCreateCounterclaim_Live(t *testing.T) {
ctx := context.Background()
userID := uuid.New()
patentID := uuid.New() // sibling parent: the patent hub
caseID := uuid.New() // the parent case (UPC_INF)
caseID := uuid.New() // the parent case (upc.inf.cfi)
// Resolve UPC_INF + UPC_REV ids once. We need real ids from the
// proceeding_types seed because they're NOT NULL on the test row.
// Resolve upc.inf.cfi + upc.rev.cfi ids once. We need real ids from
// the proceeding_types seed because they're NOT NULL on the test row.
var upcInf, upcRev int
if err := pool.GetContext(ctx, &upcInf,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_INF'`); err != nil {
t.Fatalf("resolve UPC_INF: %v", err)
`SELECT id FROM paliad.proceeding_types WHERE code = $1`,
CodeUPCInfringement); err != nil {
t.Fatalf("resolve %s: %v", CodeUPCInfringement, err)
}
if err := pool.GetContext(ctx, &upcRev,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_REV'`); err != nil {
t.Fatalf("resolve UPC_REV: %v", err)
`SELECT id FROM paliad.proceeding_types WHERE code = $1`,
CodeUPCRevocation); err != nil {
t.Fatalf("resolve %s: %v", CodeUPCRevocation, err)
}
cleanup := func() {
@@ -102,7 +104,7 @@ func TestCreateCounterclaim_Live(t *testing.T) {
patentID, userID); err != nil {
t.Fatalf("seed patent team: %v", err)
}
// Child case (UPC_INF) under the patent.
// Child case (upc.inf.cfi) under the patent.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
@@ -151,9 +153,9 @@ func TestCreateCounterclaim_Live(t *testing.T) {
if child.OurSide == nil || *child.OurSide != "defendant" {
t.Errorf("child.OurSide = %v, want defendant", child.OurSide)
}
// 4. Default proceeding_type_id resolved to UPC_REV.
// 4. Default proceeding_type_id resolved to upc.rev.cfi.
if child.ProceedingTypeID == nil || *child.ProceedingTypeID != upcRev {
t.Errorf("child.ProceedingTypeID = %v, want UPC_REV (%d)", child.ProceedingTypeID, upcRev)
t.Errorf("child.ProceedingTypeID = %v, want upc.rev.cfi (%d)", child.ProceedingTypeID, upcRev)
}
// 5. Auto-suggested title carries the patent reference + suffix.
if !strings.Contains(child.Title, "EP3456789") || !strings.Contains(child.Title, "Widerklage") {

View File

@@ -599,7 +599,7 @@ func laneLabelFor(child *models.Project, policy LevelPolicy) string {
switch policy.LaneAxis {
case "child_case":
// Append the proceeding type code when known so the lawyer can
// identify which case at a glance ("UPC-CFI München (UPC_INF)").
// identify which case at a glance ("UPC-CFI München (upc.inf.cfi)").
if child.ProceedingTypeID != nil {
return child.Title
}
@@ -1126,11 +1126,11 @@ func (s *ProjectionService) expandCrossProceedingSpawns(
Title: title,
DependsOnRuleName: src.rule.Name,
}
if first.Code != nil {
ev.RuleCode = *first.Code
if first.SubmissionCode != nil {
ev.RuleCode = *first.SubmissionCode
}
if src.rule.Code != nil {
ev.DependsOnRuleCode = *src.rule.Code
if src.rule.SubmissionCode != nil {
ev.DependsOnRuleCode = *src.rule.SubmissionCode
}
idCopy := first.ID
ev.DeadlineRuleID = &idCopy
@@ -1227,8 +1227,8 @@ func (s *ProjectionService) collectActualsForOverrides(
}
if d.RuleID != nil {
ruleIDsWithActual[*d.RuleID] = true
if r, ok := ruleByID[*d.RuleID]; ok && r.Code != nil {
overrides[*r.Code] = anchor.Format("2006-01-02")
if r, ok := ruleByID[*d.RuleID]; ok && r.SubmissionCode != nil {
overrides[*r.SubmissionCode] = anchor.Format("2006-01-02")
}
}
if d.RuleCode != nil && *d.RuleCode != "" {
@@ -1253,8 +1253,8 @@ func (s *ProjectionService) collectActualsForOverrides(
continue
}
ruleIDsWithActual[*a.RuleID] = true
if r, ok := ruleByID[*a.RuleID]; ok && r.Code != nil {
overrides[*r.Code] = a.StartAt.UTC().Format("2006-01-02")
if r, ok := ruleByID[*a.RuleID]; ok && r.SubmissionCode != nil {
overrides[*r.SubmissionCode] = a.StartAt.UTC().Format("2006-01-02")
}
}
return nil
@@ -1305,10 +1305,10 @@ func (s *ProjectionService) hydrateAppointmentRuleIDs(ctx context.Context, proje
// which the user fixes by clicking "Datum setzen" on the SoC row.
func (s *ProjectionService) deriveTriggerDate(rules []models.DeadlineRule, overrides map[string]string) string {
for _, r := range rules {
if r.ParentID != nil || r.Code == nil {
if r.ParentID != nil || r.SubmissionCode == nil {
continue
}
if anchor, ok := overrides[*r.Code]; ok {
if anchor, ok := overrides[*r.SubmissionCode]; ok {
return anchor
}
}
@@ -1578,7 +1578,7 @@ func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID
return nil, err
}
rule, err := s.lookupRuleByCode(ctx, *proj.ProceedingTypeID, in.RuleCode)
rule, err := s.lookupRuleBySubmissionCode(ctx, *proj.ProceedingTypeID, in.RuleCode)
if err != nil {
return nil, err
}
@@ -1598,8 +1598,8 @@ func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID
}
if !anchored {
parentCode := ""
if parentRule.Code != nil {
parentCode = *parentRule.Code
if parentRule.SubmissionCode != nil {
parentCode = *parentRule.SubmissionCode
}
return nil, &PredecessorMissingError{
MissingRuleCode: parentCode,
@@ -1662,19 +1662,20 @@ func (s *ProjectionService) RecordRuleSkipped(ctx context.Context, userID, proje
return nil
}
// lookupRuleByCode resolves (proceeding_type_id, code) → DeadlineRule.
func (s *ProjectionService) lookupRuleByCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) {
// lookupRuleBySubmissionCode resolves (proceeding_type_id, submission_code)
// → DeadlineRule.
func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) {
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND code = $2 AND is_active = true`,
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
ptID, code)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: unknown rule_code %q", ErrInvalidInput, code)
return nil, fmt.Errorf("%w: unknown submission_code %q", ErrInvalidInput, code)
}
if err != nil {
return nil, fmt.Errorf("lookup rule by code: %w", err)
return nil, fmt.Errorf("lookup rule by submission_code: %w", err)
}
return &rule, nil
}
@@ -1770,8 +1771,8 @@ func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, pr
id := uuid.New()
title := rule.Name
ruleCode := ""
if rule.Code != nil {
ruleCode = *rule.Code
if rule.SubmissionCode != nil {
ruleCode = *rule.SubmissionCode
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO paliad.deadlines
@@ -1883,8 +1884,8 @@ func (s *ProjectionService) annotateDependsOn(rows []TimelineEvent, rules []mode
if !ok {
continue
}
if parent.Code != nil {
ev.DependsOnRuleCode = *parent.Code
if parent.SubmissionCode != nil {
ev.DependsOnRuleCode = *parent.SubmissionCode
}
ev.DependsOnRuleName = ruleNameInLang(parent, lang)
if dt, ok := dateByRuleID[parent.ID]; ok && !dt.IsZero() {

View File

@@ -331,7 +331,7 @@ func TestExpandCrossProceedingSpawns(t *testing.T) {
// the seed uses the live post-Slice-9 column set.
_, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, name, name_en, code, duration_value, duration_unit,
(id, proceeding_type_id, name, name_en, submission_code, duration_value, duration_unit,
timing, is_court_set, is_spawn,
spawn_proceeding_type_id, sequence_order, is_active, priority,
lifecycle_state, created_at, updated_at)

View File

@@ -110,7 +110,7 @@ type CreateRuleInput struct {
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
Code *string `json:"code,omitempty"`
SubmissionCode *string `json:"submission_code,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
EventType *string `json:"event_type,omitempty"`
DurationValue int `json:"duration_value"`
@@ -168,7 +168,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
// + is_court_set are the new gates.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, code,
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
@@ -187,7 +187,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
true,
'draft', NULL, NULL,
now(), now())`,
id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.Code,
id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.SubmissionCode,
input.Name, input.NameEN, input.PrimaryParty, input.EventType,
input.DurationValue, input.DurationUnit, input.Timing,
input.AltDurationValue, input.AltDurationUnit, input.AltRuleCode, input.AnchorAlt, input.CombineOp,
@@ -286,7 +286,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
newID := uuid.New()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, code,
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
@@ -296,7 +296,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
is_active,
lifecycle_state, draft_of, published_at,
created_at, updated_at)
SELECT $1, proceeding_type_id, trigger_event_id, parent_id, concept_id, code,
SELECT $1, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,

View File

@@ -76,7 +76,7 @@ func TestRuleEditorService_Lifecycle(t *testing.T) {
Name: "SLICE11A_TEST_initial",
NameEN: "SLICE11A_TEST_initial_EN",
ProceedingTypeID: &ptID,
Code: ptrString("s11a.initial"),
SubmissionCode: ptrString("s11a.initial"),
DurationValue: 30,
DurationUnit: "days",
Priority: "mandatory",
@@ -263,7 +263,7 @@ func TestRuleEditorService_Preview(t *testing.T) {
// Slice 9 (t-paliad-195) dropped is_mandatory / is_optional.
if _, err := pool.ExecContext(ctx, `
INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, code, name, name_en,
(id, proceeding_type_id, submission_code, name, name_en,
duration_value, duration_unit, timing,
is_court_set, is_spawn,
priority, lifecycle_state, is_active, sequence_order,

View File

@@ -0,0 +1,118 @@
package services
import (
"context"
"os"
"regexp"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// submissionCodeShapeRegex is the proceeding-code-prefixed shape
// installed by mig 098 (t-paliad-209): the proceeding's 3-segment code
// (`^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.`) followed by at least one
// suffix segment (and optional further dot-separated segments). The
// regex allows digits so EPA suffixes like `r106` / `r71_3` / `r116`
// (statutory rule numbers in the suffix) pass alongside canonical
// dotted-word codes. Underscores cover the legacy archived bucket
// (`_archived_…`) and hand-seeded test rules. Mirrors the assertion in
// mig 098 §6.1.
var submissionCodeShapeRegex = regexp.MustCompile(
`^[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+\.[a-z_0-9]+(\..*)?$`)
// TestSubmissionCodeShape walks every active+published row in
// paliad.deadline_rules and asserts that submission_code matches the
// 4+-segment proceeding-code-prefixed shape ratified for t-paliad-209.
// Sibling of TestProceedingCodeShape — same pattern, same goal: catch
// drift between the migration's hard invariant and runtime state.
//
// Archived rows (proceeding `_archived_litigation`) are exempted; mig
// 098's §6.1 assertion does the same by gating on lifecycle_state =
// 'published'. Their codes get the archived prefix and the wider shape
// they end up with sits outside the 4+-segment canonical form by
// design.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the pattern in
// proceeding_codes_shape_test.go.
func TestSubmissionCodeShape(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
var rows []struct {
ID string `db:"id"`
SubmissionCode *string `db:"submission_code"`
}
if err := pool.SelectContext(ctx, &rows,
`SELECT dr.id::text AS id, dr.submission_code
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE dr.is_active = true
AND dr.lifecycle_state = 'published'
AND pt.category = 'fristenrechner'
ORDER BY dr.id`); err != nil {
t.Fatalf("load active+published deadline_rules rows: %v", err)
}
if len(rows) == 0 {
t.Fatal("no active+published fristenrechner deadline_rules — mig 098 likely not applied")
}
for _, r := range rows {
if r.SubmissionCode == nil {
t.Errorf("deadline_rules[id=%s] submission_code is NULL", r.ID)
continue
}
if !submissionCodeShapeRegex.MatchString(*r.SubmissionCode) {
t.Errorf("deadline_rules[id=%s] submission_code=%q does not match shape %s",
r.ID, *r.SubmissionCode, submissionCodeShapeRegex.String())
}
}
}
// TestSubmissionCodeShapeRegexStandalone exercises the regex without a
// DB so the shape rule is verified on every `go test ./...` run.
func TestSubmissionCodeShapeRegexStandalone(t *testing.T) {
good := []string{
"upc.inf.cfi.soc",
"upc.inf.cfi.sod",
"upc.inf.cfi.def_to_ccr",
"upc.rev.cfi.app",
"de.inf.lg.klage",
"de.inf.bgh.revision",
"de.null.bgh.berufung",
"dpma.appeal.bpatg.begruendung",
"epa.opp.opd.beschwerde_begr",
}
for _, code := range good {
if !submissionCodeShapeRegex.MatchString(code) {
t.Errorf("good code %q rejected by submission-code shape regex", code)
}
}
bad := []string{
"inf.soc", // pre-mig-098: 2 segments
"upc.inf", // 2 segments
"upc.inf.cfi", // proceeding code shape, not a submission code
"UPC.INF.CFI.SOC", // uppercase
"upc-inf-cfi-soc", // dashes
"",
}
for _, code := range bad {
if submissionCodeShapeRegex.MatchString(code) {
t.Errorf("bad code %q accepted by submission-code shape regex", code)
}
}
}