Compare commits
24 Commits
mai/planck
...
mai/curie/
| Author | SHA1 | Date | |
|---|---|---|---|
| 35aa5e63c0 | |||
| 3c9ecabf17 | |||
| aa82434af9 | |||
| 4f66feffce | |||
| bdd4999213 | |||
| cbcc67bae7 | |||
| 40e49e87d4 | |||
| 2686d43a38 | |||
| 29a6b58747 | |||
| 4361c65887 | |||
| 6fc8c0136e | |||
| 8b6b9254ed | |||
| a33060e600 | |||
| d7b2292f8f | |||
| ff8f95abaa | |||
| 84aadc838a | |||
| c4564b4031 | |||
| 7dae9b2216 | |||
| 99a72a744f | |||
| f9305d6108 | |||
| 7f72ee7b9e | |||
| d027b0874c | |||
| 7571e43078 | |||
| c7b48f6ea7 |
@@ -34,5 +34,12 @@ services:
|
||||
- PALIADIN_REMOTE_USER=${PALIADIN_REMOTE_USER}
|
||||
- PALIADIN_SSH_PRIVATE_KEY=${PALIADIN_SSH_PRIVATE_KEY}
|
||||
- PALIADIN_KNOWN_HOSTS=${PALIADIN_KNOWN_HOSTS}
|
||||
# aichat Phase B (t-paliad-194 / m/paliad#38). Set PALIADIN_BACKEND=aichat
|
||||
# to route Paliadin through the centralized aichat backend on mRiver.
|
||||
# Legacy default (unset / "legacy") keeps the existing RemotePaliadinService path.
|
||||
- PALIADIN_BACKEND=${PALIADIN_BACKEND:-legacy}
|
||||
- AICHAT_URL=${AICHAT_URL:-}
|
||||
- AICHAT_TOKEN=${AICHAT_TOKEN:-}
|
||||
- AICHAT_PERSONA=${AICHAT_PERSONA:-paliadin}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
restart: unless-stopped
|
||||
|
||||
385
docs/proposals/fristen-gap-fill-2026-05-18.md
Normal file
385
docs/proposals/fristen-gap-fill-2026-05-18.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
577
docs/proposals/orphan-concepts-2026-05-15.md
Normal file
577
docs/proposals/orphan-concepts-2026-05-15.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# Orphan Concept Seed Proposals — Fristen Phase 3 Slice 12 (t-paliad-196)
|
||||
|
||||
**Date:** 2026-05-15
|
||||
**Author:** curie (researcher)
|
||||
**Status:** DRAFT — for m's review, not yet ingested via `/admin/rules`
|
||||
**Branch:** `mai/curie/fristen-phase-3-slice-12`
|
||||
**Source audit:** `docs/audit-fristen-logic-2026-05-13.md` § 3.4 + § 7.9 (pauli)
|
||||
|
||||
---
|
||||
|
||||
## 0. Read-this-first — orphan count discrepancy
|
||||
|
||||
m's task description (and pauli's audit dated 2026-05-13) cited **nine** orphan concepts with `rule_count=0`. Today's live `paliad` DB shows **five**:
|
||||
|
||||
| # | Slug | Party | Category |
|
||||
|---|------|-------|----------|
|
||||
| 1 | `wiedereinsetzung` | both | submission |
|
||||
| 2 | `schriftsatznachreichung` | both | submission |
|
||||
| 3 | `versaeumnisurteil-einspruch` | defendant | submission |
|
||||
| 4 | `weiterbehandlung` | claimant | submission |
|
||||
| 5 | `counterclaim-for-revocation` | defendant | submission |
|
||||
|
||||
Four of the audit's nine were almost certainly seeded between 2026-05-13 and 2026-05-15 by Slice 10 (migration 090, fuzzy backfill) and the Slice-11 admin rule-editor work. `notice-of-defence-intention` is one of them: today's `DE_INF` corpus contains `de_inf.anzeige` (Anzeige der Verteidigungsbereitschaft, ZPO §276.1) linked to its own concept, which removes it from the orphan list.
|
||||
|
||||
**FLAG (count discrepancy):** I drafted proposals for the **5** remaining orphans, not 9. m should confirm whether the other 4 audit-named concepts were intentionally seeded or whether something else is going on before treating this as "done".
|
||||
|
||||
### 0.1 A second, more important framing problem
|
||||
|
||||
The orphan query `deadline_concepts.id NOT IN (SELECT concept_id FROM deadline_rules)` counts only **direct** `concept_id` linkages on `paliad.deadline_rules`. But the schema has two alternate rooting columns: `proceeding_type_id` (Pipeline A) and `trigger_event_id` (Pipeline C). The Pipeline-C migration (Slice 4, m/paliad#…) imported 77 event-rooted rules from `paliad.event_deadlines` but left their `concept_id` **NULL** on the unified `deadline_rules` table — even when the source trigger event had a matching `concept_id` slug already set on `paliad.trigger_events`.
|
||||
|
||||
Concretely, the following rules **already exist** in `paliad.deadline_rules` but lack `concept_id`:
|
||||
|
||||
| Rule name | `trigger_event_id` | Trigger event code | Owning concept (via `trigger_events.concept_id` slug) |
|
||||
|---|---|---|---|
|
||||
| Wiedereinsetzungsantrag (§ 123 PatG) | 200 | `wegfall_hindernisses_de_patg` | `wiedereinsetzung` |
|
||||
| Wiedereinsetzungsantrag (§ 233 ZPO) | 201 | `wegfall_hindernisses_de_zpo` | `wiedereinsetzung` |
|
||||
| Wiedereinsetzungsantrag (Art. 122 EPÜ) | 202 | `wegfall_hindernisses_eu_epc` | `wiedereinsetzung` |
|
||||
| Wiedereinsetzungsantrag (DPMA) | 203 | `wegfall_hindernisses_dpma` | `wiedereinsetzung` |
|
||||
| Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 204 | `zustellung_versaeumnisurteil` | `versaeumnisurteil-einspruch` |
|
||||
| Schriftsatznachreichung (§ 296a ZPO) | 205 | `ende_muendl_verhandlung` | `schriftsatznachreichung` |
|
||||
| Weiterbehandlungsantrag (Art. 121 EPÜ) | 206 | `mitteilung_rechtsverlust_eu` | `weiterbehandlung` |
|
||||
| *(none yet)* | 207 | `wegfall_hindernisses_upc` | `wiedereinsetzung` |
|
||||
|
||||
**Net effect:** four of the five "orphan" concepts already have at least one workable rule — it is just disconnected from the concept by a NULL `concept_id`. The genuine coverage gap is much smaller than "5 concepts × ~5 rules each = 25 rules to draft". Practical Phase-3-Slice-12 work splits into:
|
||||
|
||||
- **Track A (linkage, no legal review needed):** `UPDATE paliad.deadline_rules SET concept_id = … WHERE trigger_event_id IN (200,201,202,203,204,205,206)`. 7 rows, zero new legal substance. See § 6 of this doc.
|
||||
- **Track B (new rule drafts, this doc's main body):** UPC R.320 Wiedereinsetzung (`trigger_event_id=207` truly has no rule yet), proceeding-rooted variants for the four jurisdictions where having a rule under the UPC_INF / DE_INF / EPA_OPP / DPMA_OPP umbrella makes the cascade complete, plus the schema-correct way to resolve `counterclaim-for-revocation` (which is intentionally encoded as flag-gated UPC_INF rules and probably should not get fresh rules at all).
|
||||
|
||||
**FLAG (audit framing):** I recommend the orphan KPI be redefined as "concepts where NO rule references the concept, **directly via `deadline_rules.concept_id` OR transitively via `deadline_rules.trigger_event_id → trigger_events.concept_id`**". Until that happens, the orphan list will keep over-reporting work that has already been done in another column. The Phase 2 design (`docs/design-fristen-phase2-2026-05-15.md` § 3 Step C) anticipates dropping the `paliad.trigger_events` table entirely in Slice 9 and copying `concept_id` onto `deadline_rules` at that point — once that migration runs, the discrepancy resolves itself.
|
||||
|
||||
### 0.2 Convention notes
|
||||
|
||||
- Rule **code** column (`paliad.deadline_rules.code`) uses `<proceeding_short>.<action>` for proceeding-rooted rules (e.g. `inf.sod`, `de_inf.berufung`). For event-rooted rules `code` is NULL today; I follow that pattern.
|
||||
- **Anchor semantics** (audit § 4): `parent_id NULL + duration_value=0` = root anchor / court-set absolute. `parent_id NULL + duration_value>0 + trigger_event_id` = event-rooted, anchored to the trigger event's date. `parent_id NOT NULL` = chained off another rule.
|
||||
- **Priority values** (post-Slice-3): `mandatory` | `recommended` | `optional` | `informational`. Wiedereinsetzung-class rules are conceptually `optional` for the user (they may decide not to file), but the legal-source side is mandatory once invoked. I tag them `optional` with the legal source making the obligation conditional — m to confirm.
|
||||
- **`is_court_set`** is true when the deadline date is set by court order rather than computed from a statutory period. For Schriftsatznachreichung this is the relevant case; for Wiedereinsetzung/Weiterbehandlung it's false (statutory period).
|
||||
- **`legal_source`** uses the existing convention seen on live rules (`UPC.RoP.29.a`, `DE.ZPO.234.1`, `EU.EPC-R.135.1`, `EU.EPÜ.99.1`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Concept: `wiedereinsetzung` (Wiedereinsetzung in den vorigen Stand)
|
||||
|
||||
**Concept ID:** `00b737bf-58a6-4f41-9650-ac3f2e7079e8`
|
||||
**Party:** both · **Category:** submission
|
||||
**Linked event_categories (cascade leaves):**
|
||||
- `cms-eingang.gericht.rechtsverlust-epa` (Mitteilung über Rechtsverlust, EPA)
|
||||
- `frist-verpasst.de-patg` (DE Patentverfahren, PatG §123)
|
||||
- `frist-verpasst.de-zpo` (DE Zivilverfahren, ZPO §233)
|
||||
- `frist-verpasst.dpma` (DPMA, PatG §123)
|
||||
- `frist-verpasst.epa` (EPA, Art. 122 EPÜ)
|
||||
- `frist-verpasst.upc` (UPC, R.320 RoP)
|
||||
|
||||
**Existing trigger-event-rooted rules:** trigger events 200/201/202/203 already have rules in `paliad.deadline_rules` (DE PatG, DE ZPO, EPC, DPMA respectively). Only te 207 (UPC R.320) has no rule yet. See § 6 for the linkage UPDATE that brings the existing four into the concept's rule list.
|
||||
|
||||
**Drafts below:**
|
||||
|
||||
### Rule 1.1 — UPC R.320 Wiedereinsetzungsantrag
|
||||
|
||||
- **Rule code:** `upc.wiedereinsetzung` *(proceeding-rooted) ORalt. NULL code + `trigger_event_id=207` (event-rooted, matches pattern of te 200-206 rules)*
|
||||
- **Proceeding type:** UPC_INF (id=8) — primary. Also relevant for UPC_REV (9), UPC_PI (10), UPC_APP (11), UPC_DAMAGES (17), UPC_DISCOVERY (18), UPC_COST_APPEAL (19), UPC_APP_ORDERS (20). **FLAG:** Wiedereinsetzung applies across the full UPC corpus; m to decide whether to (a) seed one event-rooted rule referencing te 207 — pattern matches the existing four jurisdictions — or (b) seed seven proceeding-rooted clones. Recommend (a): cleaner, mirrors the pattern already set for DE/EPC/DPMA, and Slice 9's table-drop migration in Phase 2 will canonicalise it.
|
||||
- **Name (DE):** Wiedereinsetzungsantrag (R. 320 RoP UPC)
|
||||
- **Name (EN):** Application for re-establishment of rights (UPC R.320 RoP)
|
||||
- **Party:** both (claimant or defendant, whoever missed)
|
||||
- **Anchor:** `trigger_event_id = 207` (`wegfall_hindernisses_upc`)
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(filing is at the party's discretion — see § 0.2)*
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `UPC.RoP.320.1`
|
||||
- **Notes:** UPC R.320.1 sets a 2-month window from removal of the cause of non-compliance, capped by an absolute 1-year limit from expiry of the missed period (see Rule 1.2 below). The omitted act must be completed within the same 2-month window (R.320.2). Court fee per R.150(1)(p). UI may want to show the 1-year backstop as a sibling "Achtung" line; that is a renderer decision, not a separate rule.
|
||||
|
||||
### Rule 1.2 — UPC R.320 — 1-Jahres-Ausschlussfrist (informational)
|
||||
|
||||
- **Rule code:** `upc.wiedereinsetzung.cutoff` (or trigger-rooted with a sibling `sequence_order` after Rule 1.1)
|
||||
- **Proceeding type:** same as Rule 1.1
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** the **missed** deadline's date — not `wegfall_hindernisses_upc`. **FLAG:** Today's `trigger_events` model can't express "anchor = the missed deadline" because the trigger fires on removal of cause, not on the missed deadline. Either (a) add a new trigger event `frist_versaeumt_upc` and root this rule there, or (b) make this an `informational` UI-only rule rendered by the renderer next to Rule 1.1 with no real anchor. Recommend (b) for now; (a) is a Phase-3 schema follow-up.
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `UPC.RoP.320.1` (second half: "but at the latest within one year of the expiry of the unobserved time limit")
|
||||
- **Notes:** Cosmetically important — practitioners forget the cut-off. Keep as informational rendering until the schema supports two-anchor rules.
|
||||
|
||||
### Rule 1.3 — EPC Art. 122 / R.136 Wiedereinsetzungsantrag (EPA)
|
||||
|
||||
- **Rule code:** *(event-rooted; NULL `code`, matches existing pattern for te 200-203)*
|
||||
- **Proceeding type:** NULL (or EPA_OPP=14 / EPA_APP=15 / EP_GRANT=16 if proceeding-rooted)
|
||||
- **Name (DE):** Wiedereinsetzungsantrag (Art. 122 EPÜ)
|
||||
- **Name (EN):** Petition for re-establishment of rights (EPC Art.122)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 202` (`wegfall_hindernisses_eu_epc`)
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.136.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** `23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6` — already in `deadline_rules`, just missing `concept_id`. See § 6 linkage UPDATE; do not double-seed.
|
||||
|
||||
### Rule 1.4 — EPC R.136 — 1-Jahres-Ausschlussfrist
|
||||
|
||||
- **Rule code:** as Rule 1.2 pattern
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung EPA (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment, EPC (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** missed-deadline date (same FLAG as Rule 1.2 — schema follow-up)
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.136.1` (second sentence)
|
||||
- **Notes:** R.136(1) third sentence carves out a special **2-month** cut-off for restoration of priority (Art. 87(1) in conjunction with R.136(1)). m may want a separate rule 1.4b for that priority variant; flagging rather than auto-resolving.
|
||||
|
||||
### Rule 1.5 — DE PatG §123 Wiedereinsetzungsantrag (DPMA + national)
|
||||
|
||||
- **Rule code:** event-rooted, te=200 (PatG) and te=203 (DPMA)
|
||||
- **Name (DE):** Wiedereinsetzungsantrag (§ 123 PatG)
|
||||
- **Name (EN):** Petition for re-establishment of rights (PatG §123)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 200` (`wegfall_hindernisses_de_patg`) — for general DE PatG context — AND `trigger_event_id = 203` (`wegfall_hindernisses_dpma`) — for DPMA-specific context.
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.PatG.123.2`
|
||||
- **Notes:** **DUPLICATE of existing rules** `c24d494c-…` (te 200) and `b588fa64-…` (te 203). Linkage only — see § 6.
|
||||
|
||||
### Rule 1.6 — DE PatG §123 — 1-Jahres-Ausschlussfrist
|
||||
|
||||
- **Rule code:** as 1.2/1.4 pattern (informational)
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung PatG (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment, PatG (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** missed-deadline date (schema FLAG as 1.2)
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.PatG.123.2` (Satz 4)
|
||||
- **Notes:** PatG §123(2) Satz 4: "Innerhalb eines Jahres nach Ablauf der versäumten Frist ist keine Wiedereinsetzung mehr möglich." Same as PatG also for DPMA proceedings.
|
||||
|
||||
### Rule 1.7 — DE ZPO §233 Wiedereinsetzungsantrag (Notfrist, 2 Wochen)
|
||||
|
||||
- **Rule code:** event-rooted, te=201
|
||||
- **Name (DE):** Wiedereinsetzungsantrag — Notfrist (§ 234 Abs. 1 S. 1 ZPO)
|
||||
- **Name (EN):** Petition for re-establishment of rights — Notfrist (ZPO §234(1) sentence 1)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 201` (`wegfall_hindernisses_de_zpo`)
|
||||
- **Duration:** 2, weeks
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL — but see Rule 1.8 for the 1-month variant.
|
||||
- **Legal source:** `DE.ZPO.234.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** `d40d9be7-…` — linkage only. ZPO §234(1) sentence 1: 2 weeks for Notfristen (Berufungsfrist, Revisionsfrist, Beschwerdefrist, etc.).
|
||||
|
||||
### Rule 1.8 — DE ZPO §234(1)2 Wiedereinsetzungsantrag (Begründungsfrist, 1 Monat)
|
||||
|
||||
- **Rule code:** event-rooted, te=201, sibling to 1.7
|
||||
- **Name (DE):** Wiedereinsetzungsantrag — Begründungsfrist (§ 234 Abs. 1 S. 2 ZPO)
|
||||
- **Name (EN):** Petition for re-establishment — appeal/revision grounds period (ZPO §234(1) sentence 2)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 201` (`wegfall_hindernisses_de_zpo`)
|
||||
- **Duration:** 1, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** **FLAG** — needs a flag like `{"flag":"begruendungsfrist"}` or similar to distinguish from Rule 1.7 because today's data model can't differentiate "the missed deadline was a Berufungsbegründungsfrist" without an explicit flag from the caller. m to decide whether to add a flag or leave the rule as "informational alternative" rendered alongside 1.7.
|
||||
- **Legal source:** `DE.ZPO.234.1`
|
||||
- **Notes:** ZPO §234(1) Satz 2: "Die Frist beträgt einen Monat, wenn die Partei verhindert war, die Frist zur Begründung der Berufung, der Revision, der Nichtzulassungsbeschwerde oder der Rechtsbeschwerde oder die Frist des § 234 Abs. 3 einzuhalten."
|
||||
|
||||
### Rule 1.9 — DE ZPO §234(3) — 1-Jahres-Ausschlussfrist
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Absolute Ausschlussfrist Wiedereinsetzung ZPO (1 Jahr)
|
||||
- **Name (EN):** Absolute cut-off for re-establishment, ZPO (1 year)
|
||||
- **Party:** both
|
||||
- **Anchor:** missed-deadline date (schema FLAG as 1.2)
|
||||
- **Duration:** 12, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.234.3`
|
||||
- **Notes:** "Nach Ablauf eines Jahres, von dem Ende der versäumten Frist an gerechnet, kann die Wiedereinsetzung nicht mehr beantragt … werden."
|
||||
|
||||
**Summary for `wiedereinsetzung`:** four of the five linked event categories (DE PatG, DE ZPO, EPC, DPMA) already have **existing rules** that just need `concept_id` set — see § 6. The genuinely new substance is **Rule 1.1** (UPC R.320, te 207), plus a set of informational 1-year cut-off rules (1.2/1.4/1.6/1.9), plus the optional ZPO §234(1) sentence-2 variant (1.8). Six new rules in total, one duplicate-flagged, four pure linkages. **FLAG:** UPC fee for Wiedereinsetzung (R.150(1)(p)) is not modelled as a rule — should it appear as a sibling informational rule with the fee amount? Today's model doesn't carry money, so probably no, but worth m's call.
|
||||
|
||||
---
|
||||
|
||||
## 2. Concept: `schriftsatznachreichung` (Schriftsatznachreichung, § 296a ZPO)
|
||||
|
||||
**Concept ID:** `b7a3cb3e-ef7e-47a1-8067-be0fe35a4235`
|
||||
**Party:** both · **Category:** submission
|
||||
**Linked event_categories:**
|
||||
- `cms-eingang.gericht.ladung` (Ladung zur mündlichen Verhandlung)
|
||||
- `muendl-verhandlung.gehalten` (Soeben gehalten / heute)
|
||||
- `muendl-verhandlung.geladen` (Geladen — wann findet sie statt?)
|
||||
|
||||
**Existing rules:** te 205 (`ende_muendl_verhandlung`) already has rule `3c36f149-…` (3 weeks). Linkage only — see § 6.
|
||||
|
||||
### Rule 2.1 — DE ZPO §296a Schriftsatznachreichungsfrist
|
||||
|
||||
- **Rule code:** event-rooted, te=205
|
||||
- **Proceeding type:** NULL (event-rooted) — primarily DE_INF/DE_NULL/OLG/BGH context but cross-cutting via the trigger event.
|
||||
- **Name (DE):** Schriftsatznachreichung (§ 296a ZPO)
|
||||
- **Name (EN):** Subsequent written submission (ZPO §296a)
|
||||
- **Party:** both
|
||||
- **Anchor:** `trigger_event_id = 205` (`ende_muendl_verhandlung`)
|
||||
- **Duration:** 3, weeks
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(only available if court grants Schriftsatznachreichungsfrist; otherwise §296a bars new attack/defence means)*
|
||||
- **is_court_set:** **true** — the deadline date is set by the court order granting the Schriftsatznachreichungsfrist, not by the statute itself. ZPO §296a permits the court to set it; typical practice is 2-3 weeks but the court fixes the exact date.
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.296a`
|
||||
- **Notes:** **DUPLICATE of existing rule** — linkage only. **FLAG:** the existing rule sets `is_court_set=false` and a fixed 3-week duration. Strictly, the court sets the date, so `is_court_set=true` is more accurate; the 3-week duration is a typical-case estimate. m to decide whether to update the existing rule or leave the heuristic as-is and document the deviation.
|
||||
|
||||
### Rule 2.2 — Schriftsatznachreichung — Beschränkung auf in der Verhandlung erörterte Punkte (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Beschränkung der Schriftsatznachreichung (nur Bezug auf Verhandlungspunkte)
|
||||
- **Name (EN):** Schriftsatznachreichung scope limit (only matters raised at the hearing)
|
||||
- **Party:** both
|
||||
- **Anchor:** same as 2.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.296a`
|
||||
- **Notes:** Reminds the user that a Schriftsatznachreichung is limited to matters raised at the oral hearing — new attack/defence means are barred under §296a. Useful for the cascade card; not a calendar deadline.
|
||||
|
||||
### Rule 2.3 — Schriftsatznachreichung — UPC equivalent? (open question)
|
||||
|
||||
**FLAG:** UPC RoP has no direct §296a analogue. Post-hearing submissions in UPC proceedings are limited and require court leave (general practice; see R.117). I am intentionally **not** drafting a UPC rule under this concept and recommend m confirm the concept stays DE-only. If the cascade exposes the concept under a UPC entry, that is a cascade taxonomy bug, not a rule gap.
|
||||
|
||||
**Summary:** 2 substantive rules (1 duplicate-flagged, 1 informational). Concept is essentially solved by linkage + 1 informational sibling.
|
||||
|
||||
---
|
||||
|
||||
## 3. Concept: `versaeumnisurteil-einspruch` (Einspruch gegen Versäumnisurteil, § 339 ZPO)
|
||||
|
||||
**Concept ID:** `9f809d1d-ea06-4aa5-80d0-6feaa33b464e`
|
||||
**Party:** defendant · **Category:** submission
|
||||
**Linked event_categories:**
|
||||
- `beschluss-entscheidung.versaeumnisurteil` (Versäumnisurteil DE)
|
||||
- `cms-eingang.gericht.endentscheidung.versaeumnisurteil` (Versäumnisurteil DE)
|
||||
|
||||
**Existing rules:** te 204 (`zustellung_versaeumnisurteil`) already has rule `20254f4e-…` (2 weeks). Linkage only — see § 6.
|
||||
|
||||
### Rule 3.1 — DE ZPO §339(1) Einspruchsfrist (Inland-Zustellung, 2 Wochen)
|
||||
|
||||
- **Rule code:** event-rooted, te=204
|
||||
- **Name (DE):** Einspruch gegen Versäumnisurteil (§ 339 Abs. 1 ZPO)
|
||||
- **Name (EN):** Objection to default judgment, domestic service (ZPO §339(1))
|
||||
- **Party:** defendant
|
||||
- **Anchor:** `trigger_event_id = 204` (`zustellung_versaeumnisurteil`)
|
||||
- **Duration:** 2, weeks
|
||||
- **Timing:** after
|
||||
- **Priority:** mandatory *(if defence wants to undo default; otherwise judgment becomes final)*
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL — but see Rule 3.2 for the international-service variant.
|
||||
- **Legal source:** `DE.ZPO.339.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** — linkage only. ZPO §339(1) sentence 1: 2-week Notfrist from Zustellung. §339(1) sentence 2 reserves longer periods for cases under §339(2) and §234(2).
|
||||
|
||||
### Rule 3.2 — DE ZPO §339(2) Einspruchsfrist (Auslands-Zustellung, ≥ 1 Monat)
|
||||
|
||||
- **Rule code:** event-rooted, te=204, sibling
|
||||
- **Name (DE):** Einspruch gegen Versäumnisurteil — Auslandszustellung (§ 339 Abs. 2 ZPO)
|
||||
- **Name (EN):** Objection to default judgment — service abroad (ZPO §339(2))
|
||||
- **Party:** defendant
|
||||
- **Anchor:** `trigger_event_id = 204`
|
||||
- **Duration:** 1, months
|
||||
- **Timing:** after
|
||||
- **Priority:** mandatory
|
||||
- **is_court_set:** **true** — §339(2) sentence 2 says the court sets the period in the order; "at least one month" is the statutory floor.
|
||||
- **condition_expr:** **FLAG** — needs a flag like `{"flag":"auslandszustellung"}` to distinguish from Rule 3.1. m to decide flag naming.
|
||||
- **Legal source:** `DE.ZPO.339.2`
|
||||
- **Notes:** ZPO §339(2): "Bei einer Zustellung im Ausland nach § 183 Abs. 1 Nr. 1 wird die Einspruchsfrist auf mindestens einen Monat festgesetzt."
|
||||
|
||||
### Rule 3.3 — DE ZPO §340 Inhalt der Einspruchsschrift (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Inhalt der Einspruchsschrift (§ 340 ZPO)
|
||||
- **Name (EN):** Required contents of the objection (ZPO §340)
|
||||
- **Party:** defendant
|
||||
- **Anchor:** same as Rule 3.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.340`
|
||||
- **Notes:** Reminds the user that the Einspruchsschrift must contain the designation of the judgment, the declaration of objection, and the parties' applications. Not a calendar deadline.
|
||||
|
||||
### Rule 3.4 — Rechtsfolge Einspruch (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Rechtsfolge des zulässigen Einspruchs (§ 342 ZPO)
|
||||
- **Name (EN):** Effect of admissible objection (ZPO §342)
|
||||
- **Party:** defendant
|
||||
- **Anchor:** same as Rule 3.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `DE.ZPO.342`
|
||||
- **Notes:** Tells the user that an admissible Einspruch puts the case back in the state pre-default. Useful as a cascade-card pill; not a deadline.
|
||||
|
||||
**Summary:** 4 rules, 1 duplicate-flagged, 1 needing a condition flag, 2 informational.
|
||||
|
||||
---
|
||||
|
||||
## 4. Concept: `weiterbehandlung` (Weiterbehandlung, Art. 121 EPÜ)
|
||||
|
||||
**Concept ID:** `5a58f14c-3042-48e9-87fd-c94b62d13662`
|
||||
**Party:** claimant · **Category:** submission
|
||||
**Linked event_categories:**
|
||||
- `cms-eingang.gericht.rechtsverlust-epa` (Mitteilung über Rechtsverlust, EPA)
|
||||
- `frist-verpasst.epa` (EPA, Art. 122 EPÜ)
|
||||
|
||||
**Existing rules:** te 206 (`mitteilung_rechtsverlust_eu`) already has rule `f1099cf6-…` (2 months). Linkage only — see § 6.
|
||||
|
||||
### Rule 4.1 — EPC Art. 121 / R.135 Weiterbehandlungsantrag
|
||||
|
||||
- **Rule code:** event-rooted, te=206
|
||||
- **Name (DE):** Weiterbehandlungsantrag (Art. 121 EPÜ)
|
||||
- **Name (EN):** Request for further processing (Art.121 EPC)
|
||||
- **Party:** claimant *(applicant during prosecution)*
|
||||
- **Anchor:** `trigger_event_id = 206` (`mitteilung_rechtsverlust_eu`)
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** optional *(applicant's choice; preferred over Wiedereinsetzung when available because cheaper and no fault analysis)*
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.135.1`
|
||||
- **Notes:** **DUPLICATE of existing rule** — linkage only. R.135(1): 2 months from notification of loss of rights. Missed act must be completed; Weiterbehandlungsgebühr payable per R.135(1) third sentence.
|
||||
|
||||
### Rule 4.2 — Weiterbehandlung Ausschlüsse (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Ausschlüsse Weiterbehandlung (R.135(2) EPÜ)
|
||||
- **Name (EN):** Further-processing exclusions (EPC R.135(2))
|
||||
- **Party:** claimant
|
||||
- **Anchor:** same as Rule 4.1
|
||||
- **Duration:** 0
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.135.2`
|
||||
- **Notes:** R.135(2): Weiterbehandlung not available for the priority period (Art. 87(1)), the period under Art. 112a(4), the periods for filing of opposition and appeal (Art. 99(1), 108), and various R.6/R.36(1)(a)/R.51(2)/R.158/R.27(3) periods. Cascade-card pill so the user knows when to fall back to Wiedereinsetzung instead. **FLAG:** could be modeled per excluded period as a fine-grained `condition_expr`-gated set; that is overkill for now — informational siblings are enough.
|
||||
|
||||
### Rule 4.3 — Weiterbehandlungsgebühr (informational)
|
||||
|
||||
- **Rule code:** informational sibling
|
||||
- **Name (DE):** Weiterbehandlungsgebühr fällig
|
||||
- **Name (EN):** Further-processing fee due
|
||||
- **Party:** claimant
|
||||
- **Anchor:** same as Rule 4.1
|
||||
- **Duration:** 2, months
|
||||
- **Timing:** after
|
||||
- **Priority:** informational
|
||||
- **is_court_set:** false
|
||||
- **condition_expr:** NULL
|
||||
- **Legal source:** `EU.EPC-R.135.1` (third sentence)
|
||||
- **Notes:** Fee per Art. 2(1) item 12 of the EPA fee schedule. Mirrors the missed-act window — both must be completed in the same 2-month window for the request to be effective.
|
||||
|
||||
**Summary:** 3 rules, 1 duplicate-flagged, 2 informational.
|
||||
|
||||
---
|
||||
|
||||
## 5. Concept: `counterclaim-for-revocation` (Nichtigkeitswiderklage, UPC R.25)
|
||||
|
||||
**Concept ID:** `52134900-2bcf-4810-9de3-0b0681c79dd7`
|
||||
**Party:** defendant · **Category:** submission
|
||||
**Linked event_category:**
|
||||
- `ich-moechte-einreichen.widerklage.nichtigkeit-upc` (Nichtigkeitswiderklage UPC R.25)
|
||||
|
||||
**Existing rules:** UPC R.25 / RoP 25-32 are **already encoded** in `UPC_INF` (proceeding_type_id=8) as flag-gated rules using `condition_expr.flag = "with_ccr"`:
|
||||
|
||||
| Rule code | Name | Duration | condition_expr | concept_slug today |
|
||||
|---|---|---|---|---|
|
||||
| `inf.def_to_ccr` | Erwiderung auf Nichtigkeitswiderklage | 2 months | `{"flag":"with_ccr"}` | `defence-to-counterclaim-for-revocation` |
|
||||
| `inf.reply` (with_ccr variant) | Replik | 2 months | `{"flag":"with_ccr"}` | `reply-to-defence` |
|
||||
| `inf.reply_def_ccr` | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 months | `{"flag":"with_ccr"}` | (not yet checked) |
|
||||
| `inf.rejoin` (with_ccr) | Duplik | 1 month | `{"flag":"with_ccr"}` | `rejoinder` |
|
||||
| `inf.rejoin_reply_ccr` | Duplik auf Replik | 1 month | `{"flag":"with_ccr"}` | (not yet checked) |
|
||||
| `inf.def_to_amend` | Erwiderung auf Patentänderungsantrag | 2 months | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` | `defence-to-application-to-amend` |
|
||||
| `inf.app_to_amend` | Antrag auf Patentänderung | 2 months | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` | **NULL** (orphan column) |
|
||||
| `inf.reply_def_amd` | Replik auf Erwiderung zum Patentänderungsantrag | 1 month | same | `reply-to-defence-to-application-to-amend` (or similar) |
|
||||
| `inf.rejoin_amd` | Duplik auf Replik zum Patentänderungsantrag | 1 month | same | `rejoinder-on-amend` (or similar) |
|
||||
|
||||
**The CCR itself** — the act of filing the Nichtigkeitswiderklage — is part of `inf.sod` (Statement of Defence) when `with_ccr=true`. The 3-month SoD period from R.23 doubles as the CCR-filing period from R.25.
|
||||
|
||||
### Proposal 5.1 — Do **not** seed new rules under this concept.
|
||||
|
||||
The concept models a logical artifact ("Nichtigkeitswiderklage") that is, in the data model, an attribute of the SoD rather than a separate timed event. Seeding new rules under `counterclaim-for-revocation.concept_id` would either:
|
||||
|
||||
- (a) Duplicate the existing `inf.sod` / `inf.def_to_ccr` / etc. rules — wasteful, fragile (two sources of truth for the same legal period).
|
||||
- (b) Add a synthetic "filing CCR" rule with the same 3-month period as `inf.sod` — redundant once `inf.sod`'s `concept_id` is set correctly.
|
||||
|
||||
### Proposal 5.2 — Link existing UPC_INF rules to this concept (linkage only).
|
||||
|
||||
Specifically:
|
||||
|
||||
| Rule | Current `concept_id` link | Proposed action |
|
||||
|---|---|---|
|
||||
| `inf.sod` (UPC_INF) | `statement-of-defence` (presumably) | Leave as-is — SoD's primary concept is "Statement of Defence". |
|
||||
| `inf.app_to_amend` (UPC_INF, with_ccr+with_amend) | NULL | **Link to `counterclaim-for-revocation`** — this is the genuine "CCR-derived deadline" that has no concept today. |
|
||||
|
||||
**FLAG:** Whether the cascade entry `ich-moechte-einreichen.widerklage.nichtigkeit-upc` should resolve to the SoD itself or to a CCR-card-with-derivative-deadlines is a UX question m needs to decide. My read: when a user clicks "I want to file Nichtigkeitswiderklage", they want to see the SoD deadline (because that's when the CCR is due — same period as SoD) plus the consequential deadlines (Defence to CCR, Replik, Duplik, Patent amendment etc.). A cleaner data-model fix is to add a junction `paliad.concept_rules` (many-to-many) so a rule can belong to multiple concepts (e.g. `inf.sod` ∈ {`statement-of-defence`, `counterclaim-for-revocation`}). That's a Phase 3+ schema add and outside Slice 12's scope.
|
||||
|
||||
### Proposal 5.3 — Alternative: event-rooted CCR rule.
|
||||
|
||||
Trigger event 1 (`statement_of_defence_which_includes_a_counterclaim_for_revocation`) exists but lacks `concept_id` text. Setting `paliad.trigger_events.concept_id = 'counterclaim-for-revocation'` on te 1 and seeding 1-3 event-rooted rules that fire from te 1 (Defence to CCR within 2 months, Reply within 2 months, etc.) would give the cascade card concrete deadlines without duplicating the SoD-tree rules. This is the pattern the audit § 3.4 description hints at.
|
||||
|
||||
**Recommendation:** Proposal 5.2 + 5.3 combined. m to confirm. Until decided, I'm **not** drafting fresh rules for this concept — it's a data-model question disguised as a coverage gap.
|
||||
|
||||
---
|
||||
|
||||
## 6. Track A — Linkage-only UPDATEs (no legal review needed)
|
||||
|
||||
The following `paliad.deadline_rules` rows already exist; they only need `concept_id` pointed at the right concept. These are the lowest-risk part of Slice 12 and can be applied via the admin UI as no-op edits (or as a one-off migration if m prefers).
|
||||
|
||||
```sql
|
||||
-- DRAFT — do not run blindly; the admin UI route (PATCH /api/admin/rules/{id}) is the preferred path.
|
||||
|
||||
-- Wiedereinsetzung (DE PatG)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = 'c24d494c-0da1-4f01-aa74-0f37f99fe1ae';
|
||||
|
||||
-- Wiedereinsetzung (DE ZPO)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = 'd40d9be7-e1b6-451c-bee2-6eaee2307ec5';
|
||||
|
||||
-- Wiedereinsetzung (EPC)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = '23c6f445-4ed2-4ade-8ea0-c4ab6b364bb6';
|
||||
|
||||
-- Wiedereinsetzung (DPMA)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '00b737bf-58a6-4f41-9650-ac3f2e7079e8'
|
||||
WHERE id = 'b588fa64-a727-4cfb-a45d-69a835a3b05a';
|
||||
|
||||
-- Versäumnisurteil-Einspruch (ZPO §339)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '9f809d1d-ea06-4aa5-80d0-6feaa33b464e'
|
||||
WHERE id = '20254f4e-d213-4cf6-8f5f-1d9d36eeb6ac';
|
||||
|
||||
-- Schriftsatznachreichung (ZPO §296a)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = 'b7a3cb3e-ef7e-47a1-8067-be0fe35a4235'
|
||||
WHERE id = '3c36f149-3a81-456e-aac1-d4d18bfcb16b';
|
||||
|
||||
-- Weiterbehandlung (EPC Art.121)
|
||||
UPDATE paliad.deadline_rules
|
||||
SET concept_id = '5a58f14c-3042-48e9-87fd-c94b62d13662'
|
||||
WHERE id = 'f1099cf6-4c87-430e-b1c5-488bd44cb143';
|
||||
```
|
||||
|
||||
After these 7 rows update, `counterclaim-for-revocation` is the only remaining concept with `direct rule_count = 0`, and that is by design (see § 5).
|
||||
|
||||
---
|
||||
|
||||
## 7. Track B — Genuinely new rule drafts
|
||||
|
||||
Pure-new (not in DB today), to be added through `/admin/rules`:
|
||||
|
||||
| # | Concept | Rule | Status |
|
||||
|---|---|---|---|
|
||||
| 1.1 | `wiedereinsetzung` | UPC R.320 Wiedereinsetzungsantrag (te 207) | NEW |
|
||||
| 1.2 | `wiedereinsetzung` | UPC R.320 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 1.4 | `wiedereinsetzung` | EPC R.136 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 1.6 | `wiedereinsetzung` | DE PatG §123 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 1.8 | `wiedereinsetzung` | DE ZPO §234(1)2 — 1-Monat Begründungsfrist | NEW, condition_expr FLAG |
|
||||
| 1.9 | `wiedereinsetzung` | DE ZPO §234(3) 1-Jahres-Ausschlussfrist | NEW, schema FLAG |
|
||||
| 2.2 | `schriftsatznachreichung` | §296a-Beschränkung (informational) | NEW |
|
||||
| 3.2 | `versaeumnisurteil-einspruch` | ZPO §339(2) Auslandszustellung 1 Monat | NEW, condition_expr FLAG |
|
||||
| 3.3 | `versaeumnisurteil-einspruch` | ZPO §340 Inhalt der Einspruchsschrift (info) | NEW |
|
||||
| 3.4 | `versaeumnisurteil-einspruch` | ZPO §342 Rechtsfolge (info) | NEW |
|
||||
| 4.2 | `weiterbehandlung` | R.135(2) Ausschlüsse (info) | NEW |
|
||||
| 4.3 | `weiterbehandlung` | Weiterbehandlungsgebühr (info) | NEW |
|
||||
| 5.x | `counterclaim-for-revocation` | (none — see § 5 proposal) | — |
|
||||
|
||||
**Total new rule drafts: 12.** That is well under the "50 rule drafts" estimate in the task brief, because the linkage path covers the bulk of what looked like missing coverage. **FLAG:** if m wants me to draft additional UPC R.320 jurisdiction-specific variants (UPC_REV, UPC_PI, UPC_APP, UPC_DAMAGES, UPC_DISCOVERY) as separate proceeding-rooted rules instead of one shared event-rooted rule (Rule 1.1), that adds ~6-7 more drafts.
|
||||
|
||||
---
|
||||
|
||||
## 8. 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.
|
||||
|
||||
| ID | Section | Question |
|
||||
|---|---|---|
|
||||
| F1 | § 0 | Count discrepancy: 9 vs 5 — confirm the other 4 audit-named orphans were intentionally resolved, not lost. |
|
||||
| F2 | § 0 | Redefine the orphan KPI to also count `trigger_event_id → trigger_events.concept_id`, so the count reflects actual UX coverage. |
|
||||
| F3 | § 1.1 | UPC R.320: one event-rooted rule (te 207) vs seven proceeding-rooted clones (UPC_INF/UPC_REV/UPC_PI/UPC_APP/UPC_DAMAGES/UPC_DISCOVERY/UPC_APP_ORDERS). |
|
||||
| F4 | § 1.2, 1.4, 1.6, 1.9 | 1-year cut-off rules have no clean anchor in the current schema; informational rendering vs new `frist_versaeumt_*` trigger event. |
|
||||
| F5 | § 1.4 | EPC R.136(1) third sentence: priority-restoration 2-month cut-off — separate rule? |
|
||||
| F6 | § 1.8 | ZPO §234(1) sentence 2 (Begründungsfrist) — flag-gated or informational sibling? |
|
||||
| F7 | § 1.x | UPC Wiedereinsetzungs-Gebühr (R.150(1)(p)) — surface as informational rule or out of scope? |
|
||||
| F8 | § 2.1 | Schriftsatznachreichung existing rule has `is_court_set=false`; strictly it's court-set. Update the row or leave the heuristic in place? |
|
||||
| F9 | § 2.3 | Confirm `schriftsatznachreichung` is DE-only — cascade should not expose it under UPC entries. |
|
||||
| F10 | § 3.2 | ZPO §339(2) Auslandszustellung — flag name for `condition_expr` (e.g. `auslandszustellung`). |
|
||||
| F11 | § 5 | `counterclaim-for-revocation` — link existing UPC_INF rules (proposal 5.2) vs event-rooted CCR rule under te 1 (proposal 5.3) vs both. |
|
||||
| F12 | § 5 | Many-to-many concept↔rule junction (`paliad.concept_rules`) as a Phase 3+ schema add. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Sources cited
|
||||
|
||||
| Citation key | Reference |
|
||||
|---|---|
|
||||
| `UPC.RoP.320.1` | UPC Rules of Procedure, Rule 320(1) — Application for re-establishment of rights, time limits |
|
||||
| `UPC.RoP.320.2` | UPC RoP Rule 320(2) — Completion of omitted act |
|
||||
| `UPC.RoP.150.1.p` | UPC RoP Rule 150(1)(p) — Re-establishment fee |
|
||||
| `UPC.RoP.25` | UPC RoP Rule 25 — Lodging of Counterclaim for Revocation |
|
||||
| `UPC.RoP.23.1` | UPC RoP Rule 23(1) — Statement of Defence period (existing rule reference) |
|
||||
| `EU.EPC-R.136.1` | EPC Implementing Regulations Rule 136(1) |
|
||||
| `EU.EPC-R.136.2` | EPC Implementing Regulations Rule 136(2) — Exclusions |
|
||||
| `EU.EPC-R.135.1` | EPC Implementing Regulations Rule 135(1) — Further processing |
|
||||
| `EU.EPC-R.135.2` | EPC Implementing Regulations Rule 135(2) — Exclusions |
|
||||
| `EU.EPÜ.122` | European Patent Convention Article 122 |
|
||||
| `EU.EPÜ.121` | European Patent Convention Article 121 |
|
||||
| `DE.PatG.123.2` | German Patent Act §123(2) — Wiedereinsetzung |
|
||||
| `DE.ZPO.233` | German ZPO §233 — Wiedereinsetzung in den vorigen Stand |
|
||||
| `DE.ZPO.234.1` | German ZPO §234(1) — Antragsfrist (2 Wochen / 1 Monat) |
|
||||
| `DE.ZPO.234.3` | German ZPO §234(3) — 1-year cut-off |
|
||||
| `DE.ZPO.296a` | German ZPO §296a — Schriftsatznachreichung |
|
||||
| `DE.ZPO.339.1` | German ZPO §339(1) — Einspruchsfrist 2 Wochen |
|
||||
| `DE.ZPO.339.2` | German ZPO §339(2) — Einspruchsfrist Auslandszustellung |
|
||||
| `DE.ZPO.340` | German ZPO §340 — Inhalt der Einspruchsschrift |
|
||||
| `DE.ZPO.342` | German ZPO §342 — Rechtsfolge des zulässigen Einspruchs |
|
||||
|
||||
---
|
||||
|
||||
## 10. What's next (if m approves)
|
||||
|
||||
1. **Track A first** (low risk): apply the 7 linkage UPDATEs from § 6 via `/admin/rules` PATCH. Cascade UX immediately recovers for 4 of 5 concepts.
|
||||
2. **Track B legal-review pass:** m or HLC lawyer signs off on the 12 new drafts in § 7 — adjust durations / phrasings as needed.
|
||||
3. **Ingest Track B** via `/admin/rules` POST, one rule at a time. Each new rule goes into `lifecycle_state='draft'` first; m promotes to `published` after spot-checking via the calculator preview endpoint (Slice 11a).
|
||||
4. **Schema follow-ups** (FLAGs F2, F4, F12) deferred to Phase 3 follow-up tickets — not in Slice 12 scope.
|
||||
|
||||
**Estimated rule count after Slice 12 land:** Track A linkage = 7 connections, Track B new rules = 12 drafts → total `paliad.deadline_rules` row count grows from 249 to **261**; orphan-concept count drops from 5 to **1** (only `counterclaim-for-revocation`, which is by design — see § 5).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -370,6 +370,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.pathway.b.tree.empty": "Keine Treffer für diesen Pfad.",
|
||||
"deadlines.pathway.b.tree.reset": "Neu starten",
|
||||
"deadlines.pathway.b.tree.start_question": "Was ist passiert?",
|
||||
"deadlines.row.mode.question": "Wie suchen?",
|
||||
"deadlines.row.edit": "ändern",
|
||||
"deadlines.row.prefilled.from_akte": "aus Akte",
|
||||
"deadlines.row.reset": "Pfad zurücksetzen",
|
||||
"deadlines.row.reset.title": "Pfad zurücksetzen — alle Cascade-Antworten verwerfen",
|
||||
"deadlines.row.search.link": "Direkt suchen",
|
||||
"deadlines.row.search.link.title": "Direkt nach einer Frist suchen — überspringt den Entscheidungsbaum",
|
||||
"deadlines.row.autowalk.tooltip": "Diese Schritte ergeben sich aus Ihrer Akte. Klicken Sie „ändern\", um eine Antwort manuell anzupassen.",
|
||||
"deadlines.row.autowalk.dismiss": "Hinweis schließen",
|
||||
"deadlines.row.search.panel.back": "Zurück zum Entscheidungsbaum",
|
||||
"deadlines.row.search.panel.back.title": "Inline-Suche schließen und zum Entscheidungsbaum zurückkehren",
|
||||
"deadlines.row.search.panel.placeholder": "Frist suchen — z. B. „Klageschrift\", „Posteingang Hinweisbeschluss\"…",
|
||||
"deadlines.row.search.panel.clear": "Eingabe leeren",
|
||||
"deadlines.inbox.label": "Wo kam es an?",
|
||||
"deadlines.inbox.cms.title": "UPC — über CMS",
|
||||
"deadlines.inbox.bea.title": "Nationale Verfahren — über beA",
|
||||
@@ -1136,9 +1149,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",
|
||||
@@ -2188,6 +2201,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?",
|
||||
@@ -2925,6 +2941,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.pathway.b.tree.empty": "No matches for this path.",
|
||||
"deadlines.pathway.b.tree.reset": "Restart",
|
||||
"deadlines.pathway.b.tree.start_question": "What happened?",
|
||||
"deadlines.row.mode.question": "How to search?",
|
||||
"deadlines.row.edit": "edit",
|
||||
"deadlines.row.prefilled.from_akte": "from matter",
|
||||
"deadlines.row.reset": "Reset path",
|
||||
"deadlines.row.reset.title": "Reset path — discard all cascade answers",
|
||||
"deadlines.row.search.link": "Search directly",
|
||||
"deadlines.row.search.link.title": "Search directly for a deadline — skips the decision tree",
|
||||
"deadlines.row.autowalk.tooltip": "These steps were derived from your matter. Click \"edit\" to override any answer manually.",
|
||||
"deadlines.row.autowalk.dismiss": "Dismiss hint",
|
||||
"deadlines.row.search.panel.back": "Back to decision tree",
|
||||
"deadlines.row.search.panel.back.title": "Close inline search and return to the decision tree",
|
||||
"deadlines.row.search.panel.placeholder": "Search for a deadline — e.g. \"statement of claim\", \"hint order\"…",
|
||||
"deadlines.row.search.panel.clear": "Clear input",
|
||||
"deadlines.inbox.label": "Where did it arrive?",
|
||||
"deadlines.inbox.cms.title": "UPC — via CMS",
|
||||
"deadlines.inbox.bea.title": "National-DE — via beA",
|
||||
@@ -3672,9 +3701,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",
|
||||
@@ -4720,6 +4749,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?",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,14 +32,10 @@ export interface CalculatedDeadline {
|
||||
name: string;
|
||||
nameEN: string;
|
||||
party: string;
|
||||
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: priority is now the
|
||||
// authoritative 4-way enum. The legacy isMandatory / isOptional pair
|
||||
// is still emitted by the backend (derived via wireFlagsFromPriority)
|
||||
// for one release so this code path stays buildable across the
|
||||
// cutover. Slice 9 will drop the legacy fields server-side; this
|
||||
// interface keeps them optional so the cutover lands cleanly.
|
||||
priority?: "mandatory" | "recommended" | "optional" | "informational";
|
||||
isMandatory: boolean;
|
||||
// Priority is the canonical 4-way enum (Slice 8 made it canonical;
|
||||
// Slice 9 dropped the legacy isMandatory / isOptional pair from the
|
||||
// wire). priorityRendering(d) below branches on it.
|
||||
priority: "mandatory" | "recommended" | "optional" | "informational";
|
||||
ruleRef: string;
|
||||
legalSource?: string;
|
||||
notes?: string;
|
||||
@@ -51,28 +47,30 @@ export interface CalculatedDeadline {
|
||||
isRootEvent: boolean;
|
||||
isCourtSet: boolean;
|
||||
isCourtSetIndirect?: boolean;
|
||||
isOptional?: boolean;
|
||||
isOverridden?: boolean;
|
||||
// conditionExpr surfaces the jsonb gate predicate (design §2.4) so
|
||||
// the rule-editor + admin views can render the rule's gating shape.
|
||||
// Frontend save-modal logic doesn't read this in Slice 8; the rule
|
||||
// editor (Slice 11) will. Unknown shape on this side — pass-through.
|
||||
// Frontend save-modal logic doesn't read this; the rule editor
|
||||
// (Slice 11) is the consumer. Unknown shape on this side — pass-through.
|
||||
conditionExpr?: unknown;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
// uses post-Slice-8. Maps the unified Priority enum to:
|
||||
// uses. Maps the unified Priority enum to:
|
||||
// - preChecked: whether the save-modal pre-checks the row
|
||||
// - hideSave: whether the row renders without a save button at all
|
||||
// (informational = notice card, no save action)
|
||||
// Unknown priority (or missing on legacy responses) falls back to
|
||||
// the legacy (isMandatory, isOptional) pair semantics.
|
||||
//
|
||||
// Phase 3 Slice 9 (t-paliad-195) dropped the legacy
|
||||
// (isMandatory, isOptional) fallback that pre-Slice-8 backends
|
||||
// emitted. The backend now always populates `priority`; an unknown
|
||||
// value falls back to "render as mandatory" (safe default — never
|
||||
// silently drop a rule).
|
||||
export function priorityRendering(
|
||||
d: CalculatedDeadline,
|
||||
): { preChecked: boolean; hideSave: boolean } {
|
||||
switch (d.priority) {
|
||||
case "mandatory":
|
||||
return { preChecked: true, hideSave: false };
|
||||
case "recommended":
|
||||
return { preChecked: true, hideSave: false };
|
||||
case "optional":
|
||||
@@ -80,16 +78,9 @@ export function priorityRendering(
|
||||
case "informational":
|
||||
return { preChecked: false, hideSave: true };
|
||||
}
|
||||
// Legacy fallback: pre-Slice-8 backend responses without `priority`.
|
||||
// The wireFlagsFromPriority reverse is: T/F → mandatory, T/T → optional,
|
||||
// F/* → recommended. We surface the legacy pair semantic so existing
|
||||
// callers don't regress before the backend ships the new field.
|
||||
if (d.isMandatory && !d.isOptional) {
|
||||
return { preChecked: true, hideSave: false };
|
||||
}
|
||||
if (d.isMandatory && d.isOptional) {
|
||||
return { preChecked: false, hideSave: false };
|
||||
}
|
||||
// Unknown priority value: pre-Slice-8 backend or a forward-compat
|
||||
// future value. Safe default: render as mandatory so the rule is
|
||||
// surfaced + saved. Never silently drop.
|
||||
return { preChecked: true, hideSave: false };
|
||||
}
|
||||
|
||||
@@ -236,9 +227,12 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
|
||||
const mandatoryBadge = dl.isMandatory
|
||||
? ""
|
||||
: '<span class="optional-badge">optional</span>';
|
||||
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
|
||||
// priority directly. Optional badge fires only on 'optional'
|
||||
// priority (RoP.151-style opt-in deadlines).
|
||||
const mandatoryBadge = dl.priority === "optional"
|
||||
? '<span class="optional-badge">optional</span>'
|
||||
: "";
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -234,78 +234,74 @@ export function renderFristenrechner(): string {
|
||||
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
</h2>
|
||||
|
||||
<div className="fristen-mode-toggle" role="radiogroup" aria-label="B1/B2 mode">
|
||||
<label className="fristen-mode-toggle-option">
|
||||
<input type="radio" name="fristen-b-mode" value="tree" id="fristen-b-mode-tree" />
|
||||
<span data-i18n="deadlines.pathway.b.mode.tree">Schritt-für-Schritt (Entscheidungsbaum)</span>
|
||||
</label>
|
||||
<label className="fristen-mode-toggle-option">
|
||||
<input type="radio" name="fristen-b-mode" value="filter" id="fristen-b-mode-filter" />
|
||||
<span data-i18n="deadlines.pathway.b.mode.filter">Filter / Suche</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* B1 panel — decision tree above + concept-card results below.
|
||||
fristen-b1-cascade hosts the breadcrumb / question / button row.
|
||||
fristen-b1-results hosts the narrowing concept-card list,
|
||||
populated by runB1Search() in fristenrechner.ts. The cards
|
||||
reuse renderConceptCard() (B2's card shape).
|
||||
|
||||
m/paliad#15 follow-up: the inbox-channel chip lives at the
|
||||
top of THIS panel (not page-level) — m's call: "inside the
|
||||
decision tree because it helps us to determine what to do
|
||||
next". The chip narrows the cascade entry-points + B2 fine
|
||||
forum filter; Pathway A's Verlauf doesn't see it. */}
|
||||
{/* B1 panel — row-stack cascade.
|
||||
`#fristen-row-stack` hosts the perspective / inbox /
|
||||
cascade rows (t-paliad-180 Slice 1; t-paliad-197 Slice 2
|
||||
added project-driven prefills + auto-walk). The
|
||||
stack-header above carries the inline-search trigger
|
||||
(t-paliad-198 Slice 3 — clicking expands
|
||||
`#fristen-row-search-panel` over the row stack instead
|
||||
of routing to the legacy B2 surface) and the reset link.
|
||||
`#fristen-b1-results` is unchanged — it renders concept
|
||||
cards for both cascade-narrowing AND inline-search
|
||||
results, so users see the same card layout regardless
|
||||
of how they reached a deadline rule. */}
|
||||
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
|
||||
{/* Slice 3c — perspective chip strip. Klägerseite vs
|
||||
Beklagtenseite hides cascade leaves whose party tag
|
||||
contradicts the user's side. "Beide" / no chip
|
||||
leaves the cascade unfiltered. */}
|
||||
<div className="fristen-perspective-bar" id="fristen-perspective-bar" role="group" aria-label="Perspective">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.perspective.label">Ich vertrete:</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="claimant"
|
||||
data-i18n-title="deadlines.perspective.claimant.title" title="Klägerseite (Proactive)">
|
||||
<span data-i18n="deadlines.perspective.claimant.short">Kläger</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="defendant"
|
||||
data-i18n-title="deadlines.perspective.defendant.title" title="Beklagtenseite (Reactive)">
|
||||
<span data-i18n="deadlines.perspective.defendant.short">Beklagter</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-perspective-clear>
|
||||
<span data-i18n="deadlines.perspective.both.short">Beide</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
|
||||
default; client/fristenrechner.ts shows it when the
|
||||
active perspective came from project.our_side. The
|
||||
user can still click another chip to override. */}
|
||||
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
|
||||
data-i18n="deadlines.perspective.predefined_hint" hidden>
|
||||
vorgegeben durch Akte
|
||||
</span>
|
||||
<div className="fristen-row-stack-header" id="fristen-row-stack-header">
|
||||
<button type="button" className="fristen-row-search-link" id="fristen-row-search-link"
|
||||
data-i18n-title="deadlines.row.search.link.title"
|
||||
aria-expanded="false"
|
||||
aria-controls="fristen-row-search-panel"
|
||||
title="Direkt nach einer Frist suchen">
|
||||
<span aria-hidden="true">🔍</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.link">Direkt suchen</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-row-reset-link" id="fristen-row-reset"
|
||||
data-i18n-title="deadlines.row.reset.title"
|
||||
title="Pfad zurücksetzen — alle Cascade-Antworten verwerfen">
|
||||
<span aria-hidden="true">↺</span>{" "}
|
||||
<span data-i18n="deadlines.row.reset">Pfad zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="cms"
|
||||
data-i18n-title="deadlines.inbox.cms.title" title="UPC — über CMS">
|
||||
CMS
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
|
||||
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren — über beA">
|
||||
beA
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
|
||||
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren — Postzustellung">
|
||||
<span data-i18n="deadlines.inbox.posteingang">Posteingang</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-inbox-clear>
|
||||
<span data-i18n="deadlines.inbox.all">Alle</span>
|
||||
|
||||
{/* Inline search overlay (t-paliad-198 Slice 3). Hidden by
|
||||
default; the search icon-button in the stack header
|
||||
toggles it open / closed. While open, the row stack is
|
||||
hidden and the search input drives `#fristen-b1-results`
|
||||
directly — same surface the cascade leaf populates so
|
||||
the user sees one consistent concept-card list. */}
|
||||
<div className="fristen-row-search-panel" id="fristen-row-search-panel" hidden role="search">
|
||||
<button type="button" className="fristen-row-search-panel-back" id="fristen-row-search-panel-back"
|
||||
data-i18n-title="deadlines.row.search.panel.back.title"
|
||||
title="Zurück zum Entscheidungsbaum">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.panel.back">Zurück zum Entscheidungsbaum</span>
|
||||
</button>
|
||||
<div className="fristen-row-search-panel-input-wrap">
|
||||
<svg className="fristen-row-search-panel-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-row-search-panel-input"
|
||||
className="fristen-row-search-panel-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.row.search.panel.placeholder"
|
||||
placeholder="Frist suchen…"
|
||||
aria-label="Frist suchen"
|
||||
/>
|
||||
<button type="button" className="fristen-row-search-panel-clear" id="fristen-row-search-panel-clear"
|
||||
data-i18n-title="deadlines.row.search.panel.clear" title="Eingabe leeren" hidden>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fristen-b1-cascade" id="fristen-b1-cascade"></div>
|
||||
|
||||
<div className="fristen-row-stack" id="fristen-row-stack" aria-live="polite"></div>
|
||||
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -591,6 +591,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"
|
||||
@@ -1090,6 +1093,19 @@ export type I18nKey =
|
||||
| "deadlines.proceeding.reselect"
|
||||
| "deadlines.proceeding.selected"
|
||||
| "deadlines.reset"
|
||||
| "deadlines.row.autowalk.dismiss"
|
||||
| "deadlines.row.autowalk.tooltip"
|
||||
| "deadlines.row.edit"
|
||||
| "deadlines.row.mode.question"
|
||||
| "deadlines.row.prefilled.from_akte"
|
||||
| "deadlines.row.reset"
|
||||
| "deadlines.row.reset.title"
|
||||
| "deadlines.row.search.link"
|
||||
| "deadlines.row.search.link.title"
|
||||
| "deadlines.row.search.panel.back"
|
||||
| "deadlines.row.search.panel.back.title"
|
||||
| "deadlines.row.search.panel.clear"
|
||||
| "deadlines.row.search.panel.placeholder"
|
||||
| "deadlines.save.cta"
|
||||
| "deadlines.save.cta.adhoc.hint"
|
||||
| "deadlines.save.error"
|
||||
|
||||
@@ -1666,23 +1666,6 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-mode-toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.fristen-mode-toggle-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fristen-b1-stub {
|
||||
padding: 1.5rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
@@ -1692,72 +1675,420 @@ input[type="range"]::-moz-range-thumb {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* B1 cascade — Phase C decision-tree UI (t-paliad-133) */
|
||||
|
||||
.fristen-b1-breadcrumb {
|
||||
/* t-paliad-180 Slice 1 — Determinator row-stack primitive.
|
||||
*
|
||||
* Every decision (mode, perspective, inbox, cascade depth N) renders as
|
||||
* a `.fristen-row` with three possible states: `is-active` (user is
|
||||
* picking — expands to show chips), `is-answered` (compact single-line
|
||||
* paper-trail row), `is-prefilled` (answered but the value came from
|
||||
* the project context, marked with a `aus Akte` tag).
|
||||
*
|
||||
* Layout is column-flex: rows stack top-to-bottom, the active row's
|
||||
* chip body wraps as a responsive grid. The whole-row click on an
|
||||
* answered/prefilled row re-activates it (handled via JS row-level
|
||||
* click handlers, never a `::before { inset: 0 }` overlay — see
|
||||
* CLAUDE.md "Whole-card / whole-row click").
|
||||
*/
|
||||
.fristen-row-stack-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb {
|
||||
.fristen-row-search-link,
|
||||
.fristen-row-reset-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--color-bg-muted);
|
||||
gap: 0.35rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 120ms;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 120ms, color 120ms, background 120ms;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb:hover {
|
||||
.fristen-row-search-link:hover,
|
||||
.fristen-row-reset-link:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-subtle, var(--color-bg-muted));
|
||||
}
|
||||
|
||||
.fristen-row-search-link:focus-visible,
|
||||
.fristen-row-reset-link:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fristen-row-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* When inline search is active, the stack-header's reset link is the
|
||||
* only chrome that doesn't make sense — the search panel has its own
|
||||
* "← back to decision tree" button at the top. */
|
||||
.fristen-row-stack-header.is-inline-search-active .fristen-row-search-link,
|
||||
.fristen-row-stack-header.is-inline-search-active .fristen-row-reset-link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* t-paliad-198 Slice 3 — inline search overlay. Expands above the row
|
||||
* stack when the `🔍 Direkt suchen` button is clicked. The row stack
|
||||
* itself is hidden via the JS toggle on `#fristen-row-stack`; the
|
||||
* search input drives the same `#fristen-b1-results` container the
|
||||
* cascade narrows into. */
|
||||
.fristen-row-search-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-accent) 4%, var(--color-bg));
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-back:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-back:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.fristen-b1-crumb--current {
|
||||
.fristen-row-search-panel-input-wrap:focus-within {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-input {
|
||||
flex: 1 1 auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
padding: 0.4rem 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-clear {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-clear:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-row-search-panel-clear:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.fristen-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
|
||||
.fristen-row.is-active {
|
||||
background: var(--color-bg-subtle, var(--color-bg-muted));
|
||||
border-color: var(--color-accent);
|
||||
border-left-width: 4px;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.fristen-row.is-answered {
|
||||
background: transparent;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.fristen-row.is-prefilled {
|
||||
background: color-mix(in srgb, var(--color-accent) 6%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-border));
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.fristen-row.is-answered,
|
||||
.fristen-row.is-prefilled {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fristen-row.is-answered:hover,
|
||||
.fristen-row.is-prefilled:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-subtle, var(--color-bg-muted));
|
||||
}
|
||||
|
||||
.fristen-row-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
min-height: 1.6rem;
|
||||
}
|
||||
|
||||
.fristen-row-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fristen-row.is-active .fristen-row-num {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-text, #000);
|
||||
color: var(--color-text, #111);
|
||||
}
|
||||
|
||||
.fristen-row.is-prefilled .fristen-row-num {
|
||||
border-color: color-mix(in srgb, var(--color-accent) 50%, var(--color-border));
|
||||
}
|
||||
|
||||
.fristen-row-label {
|
||||
flex: 1 1 auto;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-b1-crumb--root {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb-sep {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.fristen-b1-question {
|
||||
margin: 0.5rem 0 1rem;
|
||||
.fristen-row.is-active .fristen-row-label {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.fristen-row-answer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-b1-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
.fristen-row-answer-check {
|
||||
color: var(--color-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fristen-b1-button {
|
||||
display: flex;
|
||||
.fristen-row-answer-icon {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fristen-row-prefilled-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Reference identifier (project.reference / "HL-2024-001") inside the
|
||||
* prefilled tag. Rendered upper-case and not italic so the project ID
|
||||
* stands out from the surrounding "aus Akte" italic text. */
|
||||
.fristen-row-prefilled-ref {
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* t-paliad-197 Slice 2 — auto-walk inline tooltip. Surfaces once per
|
||||
* user per browser when ≥ 2 cascade rows render in is-prefilled state.
|
||||
* Slots above the first prefilled row inside the stack so it reads as
|
||||
* a contextual hint rather than a global banner. The dismiss button
|
||||
* removes the element and writes a localStorage flag so the tooltip
|
||||
* never re-surfaces in this browser. */
|
||||
.fristen-row-autowalk-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 0.25rem 0 0.4rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, var(--color-border));
|
||||
border-left-width: 4px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
/* t-paliad-198 Slice 3: fade-in + slight slide-down on first paint.
|
||||
* The is-entering class is applied at injection and removed on the
|
||||
* next animation frame so the transition fires from the initial
|
||||
* offset state to the resting state. ~200ms keeps it subtle — the
|
||||
* user notices the hint without it feeling theatrical. */
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.fristen-row-autowalk-tip.is-entering {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Mobile (<640px): the tooltip sits below the prefilled row instead of
|
||||
* above it, so the user's eye lands on the row's "aus Akte" tag first
|
||||
* and reads the explanation beneath. The transform offset flips
|
||||
* accordingly. */
|
||||
@media (max-width: 640px) {
|
||||
.fristen-row.is-prefilled + .fristen-row-autowalk-tip,
|
||||
.fristen-row-autowalk-tip {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.fristen-row-autowalk-tip.is-entering {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.fristen-row-autowalk-tip-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.2;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.fristen-row-autowalk-tip-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.fristen-row-autowalk-tip-dismiss {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fristen-row-autowalk-tip-dismiss:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-row-autowalk-tip-dismiss:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.fristen-row-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, color 120ms, background 120ms;
|
||||
}
|
||||
|
||||
.fristen-row.is-answered:hover .fristen-row-edit,
|
||||
.fristen-row.is-prefilled .fristen-row-edit,
|
||||
.fristen-row:focus-within .fristen-row-edit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fristen-row-edit:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-row-edit:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.fristen-row-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-left: 2.1rem;
|
||||
}
|
||||
|
||||
.fristen-row-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
@@ -1768,32 +2099,101 @@ input[type="range"]::-moz-range-thumb {
|
||||
transition: border-color 120ms, background 120ms;
|
||||
}
|
||||
|
||||
.fristen-b1-button:hover {
|
||||
.fristen-row-chip:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-b1-button:focus-visible {
|
||||
.fristen-row-chip:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fristen-b1-button--leaf {
|
||||
/* Leaf nodes get a subtle marker so users sense they'll see results,
|
||||
not deeper buttons. */
|
||||
.fristen-row-chip.is-picked {
|
||||
border-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.fristen-row-chip--leaf {
|
||||
/* Leaf nodes get a subtle accent marker so users sense they'll see
|
||||
results, not deeper buttons. */
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.fristen-b1-button-icon {
|
||||
font-size: 1.25rem;
|
||||
.fristen-row-chip-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fristen-b1-button-label {
|
||||
.fristen-row-chip-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fristen-b1-step-back,
|
||||
/* t-paliad-198 Slice 3 — responsive row-stack breakpoints (design §7).
|
||||
*
|
||||
* Default (above): desktop layout — head + chip body share the row, chips
|
||||
* wrap into a 3-column grid via auto-fill 220px.
|
||||
*
|
||||
* 768–1023px (tablet density): same head layout but chip grid drops to
|
||||
* 2 columns (auto-fill 200px) so each chip stays comfortably tappable.
|
||||
*
|
||||
* 640–767px (small tablet / large phone): head wraps so the ändern
|
||||
* affordance moves to its own line; chip grid is single-column to keep
|
||||
* the chip body legible.
|
||||
*
|
||||
* <640px (phone): chips full-width single-column; ändern stays
|
||||
* permanently visible (no hover state to reveal it on touch); the row
|
||||
* num badge tucks into the same line as the label to save vertical
|
||||
* space.
|
||||
*/
|
||||
@media (max-width: 1023px) {
|
||||
.fristen-row-body {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.fristen-row {
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.fristen-row.is-active {
|
||||
padding: 0.85rem 0.85rem;
|
||||
}
|
||||
.fristen-row-head {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.fristen-row-body {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.4rem;
|
||||
margin-left: 1.9rem;
|
||||
}
|
||||
.fristen-row-edit {
|
||||
opacity: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.fristen-row-body {
|
||||
grid-template-columns: 1fr;
|
||||
margin-left: 0;
|
||||
}
|
||||
.fristen-row-head {
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fristen-row-answer {
|
||||
flex-basis: 100%;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
.fristen-row-edit {
|
||||
opacity: 1;
|
||||
}
|
||||
.fristen-row-chip {
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fristen-b1-loosen-link {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -1806,13 +2206,11 @@ input[type="range"]::-moz-range-thumb {
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.fristen-b1-step-back:hover,
|
||||
.fristen-b1-loosen-link:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-b1-step-back:focus-visible,
|
||||
.fristen-b1-loosen-link:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
@@ -1958,94 +2356,6 @@ input[type="range"]::-moz-range-thumb {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* m/paliad#15 inbox-channel pre-filter — slim chip strip above the
|
||||
pathway fork. Lives between tool-header and pathway-fork; styling
|
||||
echoes .fristen-search-chip but with a clearer "active" treatment so
|
||||
the user sees their persisted pref at a glance. */
|
||||
.fristen-inbox-bar,
|
||||
.fristen-perspective-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.25rem 0 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #e5e5e5);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-bg-subtle, #fafafa);
|
||||
}
|
||||
|
||||
/* Perspective bar sits above the inbox bar inside the B1 panel; tighten
|
||||
* the bottom margin so the two strips read as a stacked pair. */
|
||||
.fristen-perspective-bar {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* t-paliad-164 — "vorgegeben durch Akte" hint shown next to the
|
||||
* perspective chips when project.our_side has predefined the chip.
|
||||
* Italic, muted, with a subtle leading bullet so it reads as
|
||||
* meta-info rather than a chip. The user can still click another
|
||||
* chip to override; the hint quietly disappears when they do. */
|
||||
.fristen-perspective-hint {
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: var(--color-muted, #666);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.fristen-perspective-hint::before {
|
||||
content: "·\00a0";
|
||||
}
|
||||
|
||||
.fristen-inbox-bar-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted, #666);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.fristen-inbox-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.fristen-inbox-chip {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text, #222);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 120ms, border-color 120ms, color 120ms;
|
||||
}
|
||||
|
||||
.fristen-inbox-chip:hover {
|
||||
background: var(--color-bg-subtle, #f4f4f4);
|
||||
border-color: var(--color-text-muted, #aaa);
|
||||
}
|
||||
|
||||
.fristen-inbox-chip:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.fristen-inbox-chip--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: var(--color-text, #111);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fristen-inbox-chip--clear {
|
||||
color: var(--color-muted, #666);
|
||||
}
|
||||
|
||||
.fristen-inbox-chip--clear.fristen-inbox-chip--active {
|
||||
color: var(--color-text, #111);
|
||||
}
|
||||
|
||||
.fristen-search-results,
|
||||
.fristen-b1-results {
|
||||
margin-top: 1rem;
|
||||
@@ -10988,8 +11298,8 @@ dialog.quick-add-sheet::backdrop {
|
||||
.admin-team-filter-row,
|
||||
.fristen-pathway-fork,
|
||||
.fristen-pathway-back,
|
||||
.fristen-mode-toggle,
|
||||
.fristen-b1-cascade,
|
||||
.fristen-row-stack-header,
|
||||
.fristen-row-stack,
|
||||
.fristen-search,
|
||||
.fristen-search-chips,
|
||||
.fristen-view-toggle,
|
||||
@@ -11146,6 +11456,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;
|
||||
|
||||
32
internal/db/migrations/091_drop_legacy_rule_columns.down.sql
Normal file
32
internal/db/migrations/091_drop_legacy_rule_columns.down.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- t-paliad-195 down — reverses 091_drop_legacy_rule_columns.up.sql.
|
||||
--
|
||||
-- Restores the four columns and re-populates them from the
|
||||
-- paliad.deadline_rules_pre_091 snapshot. Rules created AFTER the
|
||||
-- mig 091 cutover (via the rule editor's POST /admin/api/rules)
|
||||
-- won't have a snapshot entry — they get NULL on the restored
|
||||
-- columns, which matches their original "never had these legacy
|
||||
-- fields" state.
|
||||
--
|
||||
-- The snapshot table itself stays (it's a permanent audit artefact);
|
||||
-- a focused follow-up slice / Slice 12 cleanup drops it once the
|
||||
-- rule editor's migration-export flow has been used to roll any
|
||||
-- post-drop edits back into version control.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 091: restore legacy columns from pre-drop snapshot',
|
||||
true);
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS is_mandatory boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS is_optional boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS condition_flag text[],
|
||||
ADD COLUMN IF NOT EXISTS condition_rule_id uuid;
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET is_mandatory = b.is_mandatory,
|
||||
is_optional = b.is_optional,
|
||||
condition_flag = b.condition_flag,
|
||||
condition_rule_id = b.condition_rule_id
|
||||
FROM paliad.deadline_rules_pre_091 b
|
||||
WHERE dr.id = b.id;
|
||||
116
internal/db/migrations/091_drop_legacy_rule_columns.up.sql
Normal file
116
internal/db/migrations/091_drop_legacy_rule_columns.up.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- t-paliad-195 / Fristen Phase 3 Slice 9 Step E (design §3.E, §9.1).
|
||||
-- m approved the downtime window 2026-05-15 ("paliad ist nicht in use
|
||||
-- heute, downtime ist egal") so the destructive drops can land.
|
||||
--
|
||||
-- This migration drops the four legacy columns on
|
||||
-- paliad.deadline_rules that the unified Phase 3 calculator no longer
|
||||
-- reads. The replacements have been backfilled (Slice 2 mig 082/083/
|
||||
-- 084), wired into the calculator (Slice 4), and on the wire (Slice 8):
|
||||
--
|
||||
-- is_mandatory → priority='mandatory' | (recommended | optional | informational)
|
||||
-- is_optional → priority='optional' (the RoP.151 T/T case)
|
||||
-- condition_flag → condition_expr (jsonb long form)
|
||||
-- condition_rule_id → DEAD (no live rows, Q13 m's approved drop)
|
||||
--
|
||||
-- Sibling drops (event_deadlines/trigger_events tables, retire of
|
||||
-- litigation category) are deferred from this slice per the live-data
|
||||
-- audit (see head ping). This file is the legacy-column-drop only.
|
||||
--
|
||||
-- Backup: paliad.deadline_rules_pre_091 snapshot of the four columns +
|
||||
-- id BEFORE the drop, so the down-migration can restore individual
|
||||
-- values if a deploy needs to roll back. The backup uses CREATE TABLE
|
||||
-- IF NOT EXISTS so a re-applied migration is a no-op.
|
||||
--
|
||||
-- Audit-reason set at the top: the mig 079 trigger fires on every
|
||||
-- UPDATE/DELETE on paliad.deadline_rules; ALTER TABLE DROP COLUMN
|
||||
-- doesn't fire the row-level trigger but the wrapper is the standard
|
||||
-- Phase 3 pattern. The reason persists in the audit log only for
|
||||
-- write paths.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 091: drop legacy rule columns per design §3.E + m''s 2026-05-15 approval',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Snapshot of the four columns + id, so the down-migration can
|
||||
-- restore values to existing rows. Skipping the snapshot table
|
||||
-- would mean a rollback adds the columns back but with NULL data;
|
||||
-- the snapshot preserves the legacy values for any downstream
|
||||
-- consumer the audit might surface.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_091 AS
|
||||
SELECT id,
|
||||
is_mandatory,
|
||||
is_optional,
|
||||
condition_flag,
|
||||
condition_rule_id,
|
||||
now() AS snapshotted_at
|
||||
FROM paliad.deadline_rules;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_091 IS
|
||||
'Snapshot of paliad.deadline_rules.(is_mandatory, is_optional, '
|
||||
'condition_flag, condition_rule_id) before mig 091''s drop. Lets '
|
||||
'a rollback restore the legacy values for the 172 rules that '
|
||||
'existed at drop time. Drop this table after Slice 9 is verified '
|
||||
'in prod (a focused follow-up slice or part of Slice 12 cleanup).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Drop the columns. Order doesn't matter — none of them reference
|
||||
-- each other or other tables (condition_rule_id was a dead self-FK
|
||||
-- that no live row uses, Q13).
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS is_mandatory,
|
||||
DROP COLUMN IF EXISTS is_optional,
|
||||
DROP COLUMN IF EXISTS condition_flag,
|
||||
DROP COLUMN IF EXISTS condition_rule_id;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Hard assertion: every remaining row carries a valid priority +
|
||||
-- has condition_expr populated when its legacy condition_flag was
|
||||
-- non-empty pre-mig. Belt-and-braces — Slice 2 backfilled both
|
||||
-- paths and Slice 4 unified the calculator, but a stale row would
|
||||
-- light up here BEFORE we hand the schema to the unified code.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_null_prio int;
|
||||
n_lost int;
|
||||
BEGIN
|
||||
SELECT count(*), count(*) FILTER (WHERE priority IS NULL)
|
||||
INTO n_total, n_null_prio
|
||||
FROM paliad.deadline_rules;
|
||||
|
||||
-- Cross-check against the snapshot: every pre-mig row with a
|
||||
-- non-empty condition_flag must have a non-NULL condition_expr
|
||||
-- post-mig. If any row lost its gate, the calculator's gate
|
||||
-- behaviour would silently change — surface it loudly.
|
||||
SELECT count(*)
|
||||
INTO n_lost
|
||||
FROM paliad.deadline_rules_pre_091 b
|
||||
JOIN paliad.deadline_rules dr ON dr.id = b.id
|
||||
WHERE b.condition_flag IS NOT NULL
|
||||
AND array_length(b.condition_flag, 1) > 0
|
||||
AND dr.condition_expr IS NULL;
|
||||
|
||||
RAISE NOTICE 'mig 091: % rules, % with NULL priority, % lost condition_expr',
|
||||
n_total, n_null_prio, n_lost;
|
||||
|
||||
IF n_null_prio > 0 THEN
|
||||
RAISE EXCEPTION 'mig 091: % rules have priority IS NULL post-drop — '
|
||||
'the priority column must be backfilled (Slice 2 mig 083) '
|
||||
'before legacy columns are dropped',
|
||||
n_null_prio;
|
||||
END IF;
|
||||
|
||||
IF n_lost > 0 THEN
|
||||
RAISE EXCEPTION 'mig 091: % rules had a condition_flag pre-drop but no '
|
||||
'condition_expr post-drop — Slice 2 mig 084 missed them',
|
||||
n_lost;
|
||||
END IF;
|
||||
END $$;
|
||||
116
internal/db/migrations/092_drop_event_deadlines_tables.down.sql
Normal file
116
internal/db/migrations/092_drop_event_deadlines_tables.down.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- t-paliad-199 down — reverses 092_drop_event_deadlines_tables.up.sql.
|
||||
--
|
||||
-- Re-creates paliad.event_deadlines + paliad.event_deadline_rule_codes
|
||||
-- with the schema they had at end of mig 086 (the read-only state right
|
||||
-- before mig 092 dropped them), repopulates from the _pre_092
|
||||
-- snapshots, restores the mig 086 read-only trigger, and drops the
|
||||
-- rule_codes column the up migration added to paliad.deadline_rules.
|
||||
--
|
||||
-- The snapshot tables themselves stay — they're the source of this
|
||||
-- rollback's data and a permanent audit artefact. A focused
|
||||
-- follow-up slice / Slice 12 cleanup drops the snapshots once
|
||||
-- Slice 9 is verified in prod.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 092: restore paliad.event_deadlines + event_deadline_rule_codes from pre-drop snapshots and drop rule_codes column',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Recreate paliad.event_deadlines. Schema matches the live state at
|
||||
-- the start of mig 092 (post-mig-086, with the notes_en column from
|
||||
-- mig 036 and the legal_source column from mig 038).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.event_deadlines (
|
||||
id bigint PRIMARY KEY,
|
||||
trigger_event_id bigint NOT NULL REFERENCES paliad.trigger_events(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
title_de text NOT NULL DEFAULT '',
|
||||
duration_value integer NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'days'
|
||||
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days')),
|
||||
timing text NOT NULL DEFAULT 'after'
|
||||
CHECK (timing IN ('before', 'after')),
|
||||
notes text NOT NULL DEFAULT '',
|
||||
alt_duration_value integer,
|
||||
alt_duration_unit text CHECK (alt_duration_unit IS NULL OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days')),
|
||||
combine_op text CHECK (combine_op IS NULL OR combine_op IN ('max', 'min')),
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
notes_en text,
|
||||
legal_source text
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS event_deadlines_trigger_event_idx
|
||||
ON paliad.event_deadlines (trigger_event_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS event_deadlines_active_idx
|
||||
ON paliad.event_deadlines (is_active) WHERE is_active = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS event_deadlines_legal_src_trgm
|
||||
ON paliad.event_deadlines USING gin (legal_source gin_trgm_ops);
|
||||
|
||||
INSERT INTO paliad.event_deadlines
|
||||
(id, trigger_event_id, title, title_de, duration_value, duration_unit,
|
||||
timing, notes, alt_duration_value, alt_duration_unit, combine_op,
|
||||
is_active, created_at, updated_at, notes_en, legal_source)
|
||||
SELECT id, trigger_event_id, title, title_de, duration_value, duration_unit,
|
||||
timing, notes, alt_duration_value, alt_duration_unit, combine_op,
|
||||
is_active, created_at, updated_at, notes_en, legal_source
|
||||
FROM paliad.event_deadlines_pre_092
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Recreate paliad.event_deadline_rule_codes.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.event_deadline_rule_codes (
|
||||
event_deadline_id bigint NOT NULL REFERENCES paliad.event_deadlines(id) ON DELETE CASCADE,
|
||||
rule_code text NOT NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (event_deadline_id, rule_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS event_deadline_rule_codes_code_idx
|
||||
ON paliad.event_deadline_rule_codes (rule_code);
|
||||
|
||||
INSERT INTO paliad.event_deadline_rule_codes
|
||||
(event_deadline_id, rule_code, sort_order)
|
||||
SELECT event_deadline_id, rule_code, sort_order
|
||||
FROM paliad.event_deadline_rule_codes_pre_092
|
||||
ON CONFLICT (event_deadline_id, rule_code) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Restore the mig 086 read-only trigger + function (the rolled-back
|
||||
-- state IS "Slice 3 + Slice 9 only", which had the trigger in place).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.event_deadlines_readonly_trigger()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'paliad.event_deadlines is read-only after Phase 3 Slice 3 — '
|
||||
'writes must go through paliad.deadline_rules (Pipeline C is '
|
||||
'unified; the source table is preserved as an audit anchor '
|
||||
'until Slice 9 drops it). Operation: %', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
CREATE TRIGGER event_deadlines_readonly
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON paliad.event_deadlines
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.event_deadlines_readonly_trigger();
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Drop the rule_codes column the up migration added. The data is
|
||||
-- preserved in paliad.event_deadline_rule_codes (just restored
|
||||
-- above), so dropping the column doesn't lose history.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS rule_codes;
|
||||
195
internal/db/migrations/092_drop_event_deadlines_tables.up.sql
Normal file
195
internal/db/migrations/092_drop_event_deadlines_tables.up.sql
Normal file
@@ -0,0 +1,195 @@
|
||||
-- t-paliad-199 / Fristen Phase 3 Slice 9 follow-up A — drop the legacy
|
||||
-- Pipeline-C source tables (paliad.event_deadlines +
|
||||
-- paliad.event_deadline_rule_codes) and the read-only trigger from
|
||||
-- mig 086, now that EventDeadlineService.Calculate has been rewritten
|
||||
-- to read from paliad.deadline_rules.
|
||||
--
|
||||
-- Lorenz's Slice 9 (t-paliad-195) deferred this drop because the
|
||||
-- legacy service still SELECTed event_deadlines.duration_value /
|
||||
-- duration_unit / timing / notes / alt_* / combine_op. Slice 9
|
||||
-- follow-up A refactors the service onto deadline_rules (the unified
|
||||
-- source-of-truth since Slice 3 / mig 085) and frees us to remove the
|
||||
-- old tables.
|
||||
--
|
||||
-- Sequencing — every step in this single migration is required for the
|
||||
-- drop to be safe:
|
||||
--
|
||||
-- 1. Snapshot both source tables into paliad.event_deadlines_pre_092
|
||||
-- + paliad.event_deadline_rule_codes_pre_092 (CREATE TABLE IF NOT
|
||||
-- EXISTS — idempotent re-run). The snapshots persist after the
|
||||
-- drop as audit anchors; the down migration restores from them.
|
||||
-- 2. ADD COLUMN rule_codes text[] to paliad.deadline_rules and
|
||||
-- backfill from paliad.event_deadline_rule_codes. Pipeline-C
|
||||
-- deadlines carry multi-code rules (e.g. R.198 / R.213 carry
|
||||
-- [RoP.029.a, RoP.030]) which don't fit deadline_rules.rule_code
|
||||
-- (singular text); mig 085 left rule_code NULL on the 77
|
||||
-- Pipeline-C rows. Without this backfill the drop would silently
|
||||
-- lose 72 RoP citations.
|
||||
-- 3. Hard assertion: every event_deadline_rule_codes row resolves to
|
||||
-- a deadline_rules row via the sequence_order = 1000 +
|
||||
-- event_deadlines.id convention from mig 085. If any row didn't
|
||||
-- land, fail loudly before dropping the source.
|
||||
-- 4. DROP TRIGGER + FUNCTION from mig 086 — orphan once the table is
|
||||
-- gone.
|
||||
-- 5. DROP TABLE paliad.event_deadline_rule_codes (FK side first).
|
||||
-- 6. DROP TABLE paliad.event_deadlines.
|
||||
-- 7. Final assertion: paliad.deadline_rules still carries >=77 active
|
||||
-- rows with trigger_event_id IS NOT NULL (the Slice 3 corpus must
|
||||
-- not have collapsed).
|
||||
--
|
||||
-- audit_reason wrapper at top — the mig 079 trigger on
|
||||
-- paliad.deadline_rules logs every row-level edit. The ALTER TABLE +
|
||||
-- UPDATE on rule_codes fires through that trigger, so the reason
|
||||
-- persists in paliad.deadline_rule_audit for forever-grade audit.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 092: drop paliad.event_deadlines + event_deadline_rule_codes after backfilling rule_codes into deadline_rules (t-paliad-199, Slice 9 follow-up A, design §3.E)',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Backup snapshots — full row copies so the down migration can
|
||||
-- rebuild both tables byte-identically. CREATE TABLE IF NOT EXISTS
|
||||
-- keeps the migration idempotent across reapplications; if the
|
||||
-- snapshot already exists from a prior aborted run, we re-use it.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.event_deadlines_pre_092 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.event_deadlines;
|
||||
|
||||
COMMENT ON TABLE paliad.event_deadlines_pre_092 IS
|
||||
'Snapshot of paliad.event_deadlines before mig 092 dropped it. '
|
||||
'Source-of-truth for the down migration; persists post-drop as the '
|
||||
'permanent audit record of the 77 Pipeline-C source rows that '
|
||||
'seeded paliad.deadline_rules via mig 085. Drop with a focused '
|
||||
'follow-up after Slice 9 is verified in prod (pair with '
|
||||
'paliad.deadline_rules_pre_091 cleanup).';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.event_deadline_rule_codes_pre_092 AS
|
||||
SELECT *, now() AS snapshotted_at
|
||||
FROM paliad.event_deadline_rule_codes;
|
||||
|
||||
COMMENT ON TABLE paliad.event_deadline_rule_codes_pre_092 IS
|
||||
'Snapshot of paliad.event_deadline_rule_codes before mig 092 dropped '
|
||||
'it. Restored by the down migration; persists post-drop as the '
|
||||
'permanent audit record of the legacy RoP citations attached to '
|
||||
'Pipeline-C deadlines (72 rows across 70 of 77 deadlines).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Add paliad.deadline_rules.rule_codes (text[]) and backfill it for
|
||||
-- the 77 Pipeline-C rules. Mig 085 set rule_code = NULL on every
|
||||
-- Pipeline-C row because deadline_rules.rule_code is singular and
|
||||
-- Pipeline-C deadlines can carry multiple citations. rule_codes
|
||||
-- holds the array form. Pipeline-A rules keep NULL here and continue
|
||||
-- using rule_code; this column is a Pipeline-C-only field today.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS rule_codes text[];
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.rule_codes IS
|
||||
'Array of legal-rule citations attached to this deadline, in '
|
||||
'render order. Pipeline-C rules (event-rooted, trigger_event_id IS '
|
||||
'NOT NULL) populate this column from the legacy '
|
||||
'paliad.event_deadline_rule_codes junction (mig 092 backfill); '
|
||||
'Pipeline-A rules use the singular rule_code column instead. NULL '
|
||||
'on Pipeline-A rules + on the 7 Pipeline-C deadlines that had no '
|
||||
'junction rows pre-mig.';
|
||||
|
||||
-- Aggregate junction rows into a text[] sorted by (sort_order,
|
||||
-- rule_code) — matches the legacy ORDER BY contract that
|
||||
-- EventDeadlineService.loadRuleCodes used.
|
||||
--
|
||||
-- Join key: the sequence_order = 1000 + event_deadlines.id convention
|
||||
-- mig 085 anchored. Every active event_deadlines.id has a corresponding
|
||||
-- deadline_rules row at sequence_order = 1000 + id; mig 085's hard
|
||||
-- assertion guarantees that.
|
||||
WITH agg AS (
|
||||
SELECT event_deadline_id,
|
||||
array_agg(rule_code ORDER BY sort_order, rule_code) AS codes
|
||||
FROM paliad.event_deadline_rule_codes
|
||||
GROUP BY event_deadline_id
|
||||
)
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET rule_codes = agg.codes
|
||||
FROM agg
|
||||
WHERE dr.trigger_event_id IS NOT NULL
|
||||
AND dr.sequence_order = 1000 + agg.event_deadline_id
|
||||
AND dr.rule_codes IS DISTINCT FROM agg.codes;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Hard assertion: every junction row landed on a deadline_rules row.
|
||||
-- Sums elements across all rule_codes arrays — if the count differs
|
||||
-- from the source junction count, some event_deadline_id failed to
|
||||
-- match any deadline_rules row (sequence_order convention broken).
|
||||
-- Fail loudly here BEFORE dropping the source.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_codes_src int;
|
||||
n_codes_target int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_codes_src
|
||||
FROM paliad.event_deadline_rule_codes;
|
||||
|
||||
SELECT COALESCE(SUM(array_length(rule_codes, 1)), 0) INTO n_codes_target
|
||||
FROM paliad.deadline_rules
|
||||
WHERE rule_codes IS NOT NULL;
|
||||
|
||||
RAISE NOTICE 'mig 092: junction rows=%, backfilled rule_codes elements=%',
|
||||
n_codes_src, n_codes_target;
|
||||
|
||||
IF n_codes_target < n_codes_src THEN
|
||||
RAISE EXCEPTION 'mig 092: rule_codes backfill missed % junction rows '
|
||||
'(source=%, target=%) — sequence_order = 1000 + ed.id '
|
||||
'convention broken? Aborting before drop.',
|
||||
n_codes_src - n_codes_target, n_codes_src, n_codes_target;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Drop the read-only trigger + function from mig 086. They're orphan
|
||||
-- once paliad.event_deadlines goes away — explicit drop documents
|
||||
-- that the wrapper's job is done, and keeps the symmetric reverse in
|
||||
-- the down migration cleanly readable.
|
||||
-- =============================================================================
|
||||
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
DROP FUNCTION IF EXISTS paliad.event_deadlines_readonly_trigger();
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. Drop the legacy tables. Order: junction first (it has a FK to
|
||||
-- event_deadlines), then the parent. Explicit ordering is clearer
|
||||
-- than relying on CASCADE and mirrors the down migration's CREATE
|
||||
-- sequence.
|
||||
-- =============================================================================
|
||||
|
||||
DROP TABLE IF EXISTS paliad.event_deadline_rule_codes;
|
||||
DROP TABLE IF EXISTS paliad.event_deadlines;
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. Final assertion: the unified Pipeline-C corpus is still intact.
|
||||
-- Mig 085 moved 77 active rows; future hand-edited Pipeline-C rules
|
||||
-- can only raise the count. A drop below 77 means the upstream
|
||||
-- deadline_rules data was clobbered while this migration ran and
|
||||
-- the deploy must abort.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_unified int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_unified
|
||||
FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id IS NOT NULL AND is_active = true;
|
||||
|
||||
RAISE NOTICE 'mig 092: post-drop Pipeline-C rule count = %', n_unified;
|
||||
|
||||
IF n_unified < 77 THEN
|
||||
RAISE EXCEPTION 'mig 092: Pipeline-C corpus collapsed — expected >=77 '
|
||||
'active deadline_rules with trigger_event_id IS NOT NULL, got %',
|
||||
n_unified;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -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';
|
||||
247
internal/db/migrations/093_retire_litigation_category.up.sql
Normal file
247
internal/db/migrations/093_retire_litigation_category.up.sql
Normal 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 $$;
|
||||
32
internal/db/migrations/094_clientmatter_six_digit.down.sql
Normal file
32
internal/db/migrations/094_clientmatter_six_digit.down.sql
Normal 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;
|
||||
97
internal/db/migrations/094_clientmatter_six_digit.up.sql
Normal file
97
internal/db/migrations/094_clientmatter_six_digit.up.sql
Normal 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 $$;
|
||||
@@ -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
|
||||
|
||||
@@ -473,7 +473,6 @@ type DeadlineRule struct {
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
|
||||
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||
@@ -481,13 +480,6 @@ type DeadlineRule struct {
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
|
||||
// ConditionFlag holds zero or more flag codes that gate this rule.
|
||||
// Semantics: rule renders iff every element is present in
|
||||
// CalcOptions.Flags. Empty/NULL = unconditional. When all flags are
|
||||
// satisfied AND alt_duration_value is non-NULL the calculator swaps
|
||||
// to alt_*; when set + flags not satisfied the rule is suppressed.
|
||||
ConditionFlag pq.StringArray `db:"condition_flag" json:"condition_flag,omitempty"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
@@ -502,21 +494,16 @@ type DeadlineRule struct {
|
||||
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
// IsOptional flags a rule whose deadline is conditional on a user
|
||||
// act (e.g. RoP.151 cost-decision request — only fires when a
|
||||
// party files for it). Save-modal pre-unchecks optional rows; the
|
||||
// timeline still renders them so the user knows what could apply.
|
||||
IsOptional bool `db:"is_optional" json:"is_optional"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
|
||||
// Populated by Slice 2 backfill; readers are compat-mode (read
|
||||
// both shapes) until Slice 4 cuts the calculator over and Slice 9
|
||||
// drops the legacy columns above (IsMandatory, IsOptional,
|
||||
// ConditionFlag, ConditionRuleID).
|
||||
// Slice 9 (t-paliad-195) dropped the legacy IsMandatory /
|
||||
// IsOptional / ConditionFlag / ConditionRuleID fields — they
|
||||
// were superseded by Priority / ConditionExpr / IsCourtSet and
|
||||
// the unified calculator no longer reads them.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,20 +23,15 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||
|
||||
// ruleColumns lists every column scanned into models.DeadlineRule.
|
||||
//
|
||||
// Compat-mode (t-paliad-182 Phase 3 Slice 1): the SELECT reads BOTH
|
||||
// the legacy shape (is_mandatory, is_optional, condition_flag,
|
||||
// condition_rule_id) and the unified Phase 3 shape (trigger_event_id,
|
||||
// spawn_proceeding_type_id, combine_op, condition_expr, priority,
|
||||
// is_court_set, lifecycle_state, draft_of, published_at). Existing
|
||||
// callers stay on the legacy fields; the new fields are NULL or carry
|
||||
// their migration default until Slice 2 backfills them. Slice 4 cuts
|
||||
// the calculator over to the new fields, Slice 9 drops the legacy
|
||||
// columns.
|
||||
// Slice 9 (t-paliad-195, mig 091) dropped is_mandatory, is_optional,
|
||||
// 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,
|
||||
description, primary_party, event_type, is_mandatory, duration_value,
|
||||
description, primary_party, event_type, duration_value,
|
||||
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
condition_rule_id, condition_flag, alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_optional, is_active,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at`
|
||||
|
||||
@@ -288,97 +288,45 @@ func TestDeadlineRuleService_BackfillIntegrity(t *testing.T) {
|
||||
t.Errorf("found %d rules with NULL priority — mig 083 incomplete or CHECK bypassed", nullPriority)
|
||||
}
|
||||
|
||||
type prioRow struct {
|
||||
IsMandatory bool `db:"is_mandatory"`
|
||||
IsOptional bool `db:"is_optional"`
|
||||
Priority string `db:"priority"`
|
||||
N int `db:"n"`
|
||||
}
|
||||
var prioBuckets []prioRow
|
||||
if err := pool.SelectContext(ctx, &prioBuckets, `
|
||||
SELECT is_mandatory, is_optional, priority, count(*) AS n
|
||||
FROM paliad.deadline_rules
|
||||
GROUP BY is_mandatory, is_optional, priority
|
||||
ORDER BY is_mandatory, is_optional, priority`); err != nil {
|
||||
t.Fatalf("bucket priorities: %v", err)
|
||||
}
|
||||
expectedPriority := func(isMand, isOpt bool) string {
|
||||
switch {
|
||||
case isMand && !isOpt:
|
||||
return "mandatory"
|
||||
case isMand && isOpt:
|
||||
return "optional"
|
||||
default: // F/T and F/F both map to 'recommended' per design §2.3.
|
||||
return "recommended"
|
||||
}
|
||||
}
|
||||
for _, row := range prioBuckets {
|
||||
want := expectedPriority(row.IsMandatory, row.IsOptional)
|
||||
if row.Priority != want {
|
||||
t.Errorf("(is_mandatory=%v, is_optional=%v) → priority=%q on %d rules, want %q",
|
||||
row.IsMandatory, row.IsOptional, row.Priority, row.N, want)
|
||||
}
|
||||
}
|
||||
// Slice 9 (t-paliad-195) dropped the legacy is_mandatory / is_optional
|
||||
// columns; pre-drop the test bucketed by the legacy pair to verify
|
||||
// Slice 2's backfill mapping. Post-Slice-9 the only remaining
|
||||
// invariant is "every row has a valid priority enum value", which
|
||||
// the nullPriority check above already asserts. The pre-drop
|
||||
// snapshot lives in paliad.deadline_rules_pre_091; a rollback
|
||||
// could rerun the full bucket check there.
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3. condition_expr backfill matches design §2.4.
|
||||
// 3. condition_expr remains populated for the 17 originally-flagged
|
||||
// rules. We can no longer cross-check against condition_flag (the
|
||||
// column is gone in Slice 9) — instead, assert that the count of
|
||||
// non-NULL condition_expr rows matches the pre-mig-091 snapshot's
|
||||
// count of non-empty condition_flag rows (17 expected). If the
|
||||
// snapshot table is gone (a follow-up cleanup slice drops it),
|
||||
// skip this assertion gracefully.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Every non-empty condition_flag has a non-NULL condition_expr.
|
||||
var orphans int
|
||||
if err := pool.GetContext(ctx, &orphans, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE condition_flag IS NOT NULL
|
||||
AND array_length(condition_flag, 1) > 0
|
||||
AND condition_expr IS NULL`); err != nil {
|
||||
t.Fatalf("count condition_flag orphans: %v", err)
|
||||
}
|
||||
if orphans != 0 {
|
||||
t.Errorf("%d rules carry condition_flag but no condition_expr — mig 084 incomplete", orphans)
|
||||
}
|
||||
|
||||
// Every NULL/empty condition_flag has NULL condition_expr (no spurious writes).
|
||||
var spurious int
|
||||
if err := pool.GetContext(ctx, &spurious, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE (condition_flag IS NULL OR array_length(condition_flag, 1) IS NULL)
|
||||
AND condition_expr IS NOT NULL`); err != nil {
|
||||
t.Fatalf("count condition_expr spurious: %v", err)
|
||||
}
|
||||
if spurious != 0 {
|
||||
t.Errorf("%d rules carry condition_expr without condition_flag — mig 084 over-wrote", spurious)
|
||||
}
|
||||
|
||||
// Single-flag shape: condition_expr = {"flag":"<name>"} matches
|
||||
// condition_flag[1]. Use jsonb -> to extract the flag scalar.
|
||||
var singleMismatch int
|
||||
if err := pool.GetContext(ctx, &singleMismatch, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE array_length(condition_flag, 1) = 1
|
||||
AND condition_expr ->> 'flag' IS DISTINCT FROM condition_flag[1]`); err != nil {
|
||||
t.Fatalf("count single-flag mismatch: %v", err)
|
||||
}
|
||||
if singleMismatch != 0 {
|
||||
t.Errorf("%d single-flag rules have condition_expr.flag ≠ condition_flag[1]", singleMismatch)
|
||||
}
|
||||
|
||||
// Multi-flag shape: condition_expr.op='and', args length = flag count,
|
||||
// each args[i].flag = condition_flag[i+1] (1-indexed).
|
||||
var multiMismatch int
|
||||
if err := pool.GetContext(ctx, &multiMismatch, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE array_length(condition_flag, 1) >= 2
|
||||
AND (
|
||||
condition_expr ->> 'op' IS DISTINCT FROM 'and'
|
||||
OR jsonb_array_length(condition_expr -> 'args') IS DISTINCT FROM array_length(condition_flag, 1)
|
||||
)`); err != nil {
|
||||
t.Fatalf("count multi-flag mismatch: %v", err)
|
||||
}
|
||||
if multiMismatch != 0 {
|
||||
t.Errorf("%d multi-flag rules have malformed condition_expr (op/args shape)", multiMismatch)
|
||||
// Cross-check via the pre-mig-091 snapshot (defensive — Slice 9
|
||||
// preserved it for rollback). If the snapshot is around, every
|
||||
// non-empty condition_flag row in the snapshot should map to a
|
||||
// non-NULL condition_expr in the live table.
|
||||
var snapshotExists bool
|
||||
_ = pool.GetContext(ctx, &snapshotExists, `
|
||||
SELECT EXISTS (SELECT 1 FROM pg_tables
|
||||
WHERE schemaname='paliad' AND tablename='deadline_rules_pre_091')`)
|
||||
if snapshotExists {
|
||||
var orphans int
|
||||
if err := pool.GetContext(ctx, &orphans, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules_pre_091 b
|
||||
JOIN paliad.deadline_rules dr ON dr.id = b.id
|
||||
WHERE b.condition_flag IS NOT NULL
|
||||
AND array_length(b.condition_flag, 1) > 0
|
||||
AND dr.condition_expr IS NULL`); err != nil {
|
||||
t.Fatalf("snapshot cross-check: %v", err)
|
||||
}
|
||||
if orphans != 0 {
|
||||
t.Errorf("%d rules had condition_flag in snapshot but no condition_expr live — mig 084 missed them", orphans)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// EventDeadlineService backs the "Was kommt nach…" Fristenrechner mode:
|
||||
@@ -18,29 +20,34 @@ import (
|
||||
// Phase 3 Slice 3 (t-paliad-184) refactor: the math + rule SELECT moved
|
||||
// into FristenrechnerService.calculateByTriggerEvent (which reads from
|
||||
// the unified paliad.deadline_rules backed by mig 085's data-move).
|
||||
// EventDeadlineService.Calculate now delegates and wraps the unified
|
||||
// response in the legacy CalculateResponse shape (trigger metadata +
|
||||
// per-deadline rule_codes from event_deadline_rule_codes). The public
|
||||
// signature stays unchanged so /api/tools/event-deadlines callers see
|
||||
// no diff.
|
||||
// EventDeadlineService.Calculate delegated and wrapped the unified
|
||||
// response in the legacy CalculateResponse shape, but still SELECTed
|
||||
// paliad.event_deadlines + paliad.event_deadline_rule_codes for the
|
||||
// per-row metadata (DurationValue, DurationUnit, Timing, Notes, RuleCodes,
|
||||
// alt_*, combine_op).
|
||||
//
|
||||
// Phase 3 Slice 9 follow-up A (t-paliad-199): EventDeadlineService now
|
||||
// reads source rows from paliad.deadline_rules directly — the
|
||||
// trigger_event_id IS NOT NULL filter scopes to the 77 Pipeline-C rows
|
||||
// mig 085 unified. Multi-code citations (the legacy
|
||||
// event_deadline_rule_codes junction) live in the new
|
||||
// paliad.deadline_rules.rule_codes text[] column populated by mig 092's
|
||||
// backfill. event_deadlines + event_deadline_rule_codes are dropped by
|
||||
// mig 092; the service no longer references either.
|
||||
//
|
||||
// Phase 3 Slice 4 (t-paliad-185) collapsed the prior on-service
|
||||
// applyDuration / addWorkingDays helpers into package-level functions
|
||||
// shared with FristenrechnerService — single source-of-truth for
|
||||
// timing / working_days / holiday-rollover arithmetic.
|
||||
type EventDeadlineService struct {
|
||||
db *sqlx.DB
|
||||
calc *DeadlineCalculator
|
||||
holidays *HolidayService
|
||||
courts *CourtService
|
||||
db *sqlx.DB
|
||||
calc *DeadlineCalculator
|
||||
holidays *HolidayService
|
||||
courts *CourtService
|
||||
fristenrechner *FristenrechnerService
|
||||
}
|
||||
|
||||
// NewEventDeadlineService wires the service to its dependencies. The
|
||||
// fristenrechner is the Phase 3 delegate target — pre-Slice-3 wiring
|
||||
// can pass nil there and the legacy SELECT path is still used at
|
||||
// runtime via the (currently unreachable) fallback below; today every
|
||||
// caller supplies it.
|
||||
// NewEventDeadlineService wires the service to its dependencies.
|
||||
func NewEventDeadlineService(db *sqlx.DB, calc *DeadlineCalculator, holidays *HolidayService, courts *CourtService, fristenrechner *FristenrechnerService) *EventDeadlineService {
|
||||
return &EventDeadlineService{
|
||||
db: db,
|
||||
@@ -107,20 +114,29 @@ type CalculateResponse struct {
|
||||
|
||||
// Calculate resolves all deadlines flowing from a trigger event + date.
|
||||
//
|
||||
// Phase 3 Slice 3 (t-paliad-184) delegates the rule SELECT + math to
|
||||
// Phase 3 Slice 3 (t-paliad-184) delegated the rule SELECT + math to
|
||||
// FristenrechnerService.calculateByTriggerEvent — which reads from
|
||||
// paliad.deadline_rules WHERE trigger_event_id = X (the rows mig 085
|
||||
// moved out of event_deadlines). This method now owns the wrapping
|
||||
// concerns: trigger-event metadata lookup, rule_code aggregation (via
|
||||
// the still-readable event_deadline_rule_codes junction), and the
|
||||
// composite-rule note string that the legacy /api/tools/event-deadlines
|
||||
// contract emits.
|
||||
// moved out of event_deadlines).
|
||||
//
|
||||
// The legacy event_deadlines table is the source-of-truth for
|
||||
// (durationValue, durationUnit, timing, notes_en, alt_*, combine_op,
|
||||
// id) until Slice 9 drops it. Reading those fields here keeps the
|
||||
// frontend's EventDeadlineResult shape pixel-identical with pre-Slice-3
|
||||
// — verified by the 77-row parity test in event_deadline_service_test.go.
|
||||
// Phase 3 Slice 9 follow-up A (t-paliad-199): the per-row metadata
|
||||
// SELECT now also reads from paliad.deadline_rules. Mig 092 dropped
|
||||
// paliad.event_deadlines + paliad.event_deadline_rule_codes after
|
||||
// backfilling the multi-code junction rows into
|
||||
// paliad.deadline_rules.rule_codes (text[]). The legacy
|
||||
// EventDeadlineResult shape is built by mapping fields:
|
||||
//
|
||||
// deadline_rules.name → EventDeadlineResult.TitleDE
|
||||
// deadline_rules.name_en → EventDeadlineResult.Title
|
||||
// deadline_rules.deadline_notes → EventDeadlineResult.Notes
|
||||
// deadline_rules.deadline_notes_en → EventDeadlineResult.NotesEN
|
||||
// deadline_rules.rule_codes → EventDeadlineResult.RuleCodes
|
||||
// deadline_rules.sequence_order → EventDeadlineResult.ID
|
||||
// (legacy event_deadlines.id semantic via mig 085's
|
||||
// sequence_order = 1000 + event_deadlines.id convention)
|
||||
//
|
||||
// The public /api/tools/event-deadlines wire shape is unchanged from
|
||||
// pre-Slice-9-followup-A — only the backing query changes.
|
||||
//
|
||||
// courtID may be empty for legacy callers — defaults to UPC München
|
||||
// (DE country, UPC regime) for the trigger-event surface.
|
||||
@@ -139,34 +155,37 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
|
||||
// Source-of-truth columns the unified UIResponse drops (the
|
||||
// frontend still reads DurationValue/Unit/Timing literally to render
|
||||
// the "X days after" pill). SELECT from event_deadlines is still
|
||||
// allowed — the mig 086 read-only trigger only blocks writes.
|
||||
var rows []eventDeadlineRow
|
||||
// the "X days after" pill). Reading from paliad.deadline_rules with
|
||||
// trigger_event_id = $1 — the same row set FristenrechnerService.
|
||||
// calculateByTriggerEvent uses, so a join by rule.ID is exact.
|
||||
// COALESCE(timing, 'after') matches the column default. Pipeline-C
|
||||
// rows seeded by mig 085 always carry an explicit timing (the
|
||||
// source event_deadlines.timing was NOT NULL); the COALESCE guards
|
||||
// any future hand-edited rule that left the column NULL.
|
||||
var rows []eventDeadlineRuleRow
|
||||
err = s.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, title, title_de, duration_value, duration_unit, timing,
|
||||
notes, notes_en, alt_duration_value, alt_duration_unit, combine_op
|
||||
FROM paliad.event_deadlines
|
||||
SELECT id, sequence_order, name, name_en, duration_value, duration_unit,
|
||||
COALESCE(timing, 'after') AS timing,
|
||||
deadline_notes, deadline_notes_en, alt_duration_value, alt_duration_unit,
|
||||
combine_op, rule_codes
|
||||
FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id = $1 AND is_active = true
|
||||
ORDER BY id`, triggerEventID)
|
||||
ORDER BY sequence_order`, triggerEventID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load deadlines: %w", err)
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(rows))
|
||||
byTitleDE := make(map[string]eventDeadlineRow, len(rows))
|
||||
byRuleID := make(map[uuid.UUID]eventDeadlineRuleRow, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
byTitleDE[r.TitleDE] = r
|
||||
}
|
||||
codes, err := s.loadRuleCodes(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
byRuleID[r.ID] = r
|
||||
}
|
||||
|
||||
// Delegate to the unified calculator. UIResponse comes back with the
|
||||
// adjusted/original dates + wasAdjusted; the per-rule metadata is
|
||||
// the same names + ordering the source rows above carry, so we can
|
||||
// merge them on .Name (which mig 085 copied from event_deadlines.title_de).
|
||||
// adjusted/original dates + wasAdjusted; UIDeadline.RuleID is
|
||||
// rule.ID.String(), so we can merge precisely on the rule UUID
|
||||
// without relying on title_de string equality (the pre-Slice-9
|
||||
// shape) — a fragile match if a rule's name ever diverges from its
|
||||
// source.
|
||||
unified, err := s.fristenrechner.Calculate(ctx, "", triggerDateStr, CalcOptions{
|
||||
TriggerEventIDFilter: &triggerEventID,
|
||||
CourtID: courtID,
|
||||
@@ -175,15 +194,33 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Holiday/regime resolution is cheap but happens up to N times in
|
||||
// the composite-recompute loop below; pull it out so we hit the
|
||||
// CourtService once per call.
|
||||
country, regime, cerr := s.courts.CountryRegime(courtID, CountryDE, RegimeUPC)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
triggerDate, terr := time.Parse("2006-01-02", triggerDateStr)
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, terr)
|
||||
}
|
||||
|
||||
results := make([]EventDeadlineResult, 0, len(unified.Deadlines))
|
||||
for _, d := range unified.Deadlines {
|
||||
src, ok := byTitleDE[d.Name]
|
||||
ruleID, perr := uuid.Parse(d.RuleID)
|
||||
if perr != nil {
|
||||
// UIDeadline.RuleID is always rule.ID.String() — a non-UUID
|
||||
// here would mean a calculator bug. Skip defensively rather
|
||||
// than fail the request.
|
||||
continue
|
||||
}
|
||||
src, ok := byRuleID[ruleID]
|
||||
if !ok {
|
||||
// Defensive: a unified row exists for which no source
|
||||
// event_deadlines row matches by title_de. Either a hand-
|
||||
// inserted Pipeline-C rule (post-Slice-3) without a source
|
||||
// counterpart, or a name divergence. Skip it from the legacy
|
||||
// shape and let the parity test surface the mismatch.
|
||||
// deadline_rules row matches by ID. Should be impossible
|
||||
// since both branches read the same rows; skip rather than
|
||||
// emit a broken row.
|
||||
continue
|
||||
}
|
||||
isComposite := src.CombineOp != nil && src.AltDurationValue != nil && src.AltDurationUnit != nil
|
||||
@@ -192,14 +229,6 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
// Recompute which leg won by re-running applyDuration with
|
||||
// the source's exact inputs — cheaper than threading the
|
||||
// pick through the unified UIDeadline shape.
|
||||
country, regime, cerr := s.courts.CountryRegime(courtID, CountryDE, RegimeUPC)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
triggerDate, terr := time.Parse("2006-01-02", triggerDateStr)
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, terr)
|
||||
}
|
||||
_, baseAdj, _, _ := applyDuration(triggerDate, src.DurationValue, src.DurationUnit, src.Timing, country, regime, s.holidays)
|
||||
_, altAdj, _, _ := applyDuration(triggerDate, *src.AltDurationValue, *src.AltDurationUnit, src.Timing, country, regime, s.holidays)
|
||||
pickedUnit := src.DurationUnit
|
||||
@@ -219,20 +248,39 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
*src.AltDurationValue, *src.AltDurationUnit,
|
||||
pickedUnit)
|
||||
}
|
||||
notes := ""
|
||||
if src.DeadlineNotes != nil {
|
||||
notes = *src.DeadlineNotes
|
||||
}
|
||||
notesEN := ""
|
||||
if src.NotesEN != nil {
|
||||
notesEN = *src.NotesEN
|
||||
if src.DeadlineNotesEn != nil {
|
||||
notesEN = *src.DeadlineNotesEn
|
||||
}
|
||||
// rule_codes is NULL when the Pipeline-C rule had no junction
|
||||
// rows pre-mig-092 (7 of 77 deadlines). Emit an empty slice in
|
||||
// that case so the JSON contract stays `"ruleCodes": []` rather
|
||||
// than `null`.
|
||||
ruleCodes := []string(src.RuleCodes)
|
||||
if ruleCodes == nil {
|
||||
ruleCodes = []string{}
|
||||
}
|
||||
results = append(results, EventDeadlineResult{
|
||||
ID: src.ID,
|
||||
Title: src.Title,
|
||||
TitleDE: src.TitleDE,
|
||||
// Legacy event_deadlines.id semantic: mig 085 set
|
||||
// sequence_order = 1000 + event_deadlines.id, so the
|
||||
// pre-Slice-9-followup-A integer IDs (1..206) round-trip
|
||||
// via sequence_order - 1000. Preserves the wire contract
|
||||
// for the existing 77 Pipeline-C rows; Pipeline-C rules
|
||||
// added by the rule editor get whatever sequence_order
|
||||
// the editor assigns (no event_deadlines counterpart).
|
||||
ID: int64(src.SequenceOrder - 1000),
|
||||
Title: src.NameEN,
|
||||
TitleDE: src.Name,
|
||||
DurationValue: src.DurationValue,
|
||||
DurationUnit: src.DurationUnit,
|
||||
Timing: src.Timing,
|
||||
Notes: src.Notes,
|
||||
Notes: notes,
|
||||
NotesEN: notesEN,
|
||||
RuleCodes: codes[src.ID],
|
||||
RuleCodes: ruleCodes,
|
||||
DueDate: d.DueDate,
|
||||
OriginalDueDate: d.OriginalDate,
|
||||
WasAdjusted: d.WasAdjusted,
|
||||
@@ -248,49 +296,24 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
}, nil
|
||||
}
|
||||
|
||||
// eventDeadlineRow is the package-private row shape used by Calculate's
|
||||
// SELECT. Keeps optional fields as pointers (nil = no composite alt-leg).
|
||||
type eventDeadlineRow struct {
|
||||
ID int64 `db:"id"`
|
||||
Title string `db:"title"`
|
||||
TitleDE string `db:"title_de"`
|
||||
DurationValue int `db:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit"`
|
||||
Timing string `db:"timing"`
|
||||
Notes string `db:"notes"`
|
||||
NotesEN *string `db:"notes_en"`
|
||||
AltDurationValue *int `db:"alt_duration_value"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit"`
|
||||
CombineOp *string `db:"combine_op"`
|
||||
}
|
||||
|
||||
// loadRuleCodes batches one query for all deadline IDs.
|
||||
func (s *EventDeadlineService) loadRuleCodes(ctx context.Context, ids []int64) (map[int64][]string, error) {
|
||||
if len(ids) == 0 {
|
||||
return map[int64][]string{}, nil
|
||||
}
|
||||
|
||||
type codeRow struct {
|
||||
EventDeadlineID int64 `db:"event_deadline_id"`
|
||||
RuleCode string `db:"rule_code"`
|
||||
}
|
||||
var crs []codeRow
|
||||
q, args, err := sqlx.In(`
|
||||
SELECT event_deadline_id, rule_code
|
||||
FROM paliad.event_deadline_rule_codes
|
||||
WHERE event_deadline_id IN (?)
|
||||
ORDER BY event_deadline_id, sort_order, rule_code`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build rule_code query: %w", err)
|
||||
}
|
||||
q = s.db.Rebind(q)
|
||||
if err := s.db.SelectContext(ctx, &crs, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("load rule codes: %w", err)
|
||||
}
|
||||
|
||||
out := make(map[int64][]string, len(ids))
|
||||
for _, c := range crs {
|
||||
out[c.EventDeadlineID] = append(out[c.EventDeadlineID], c.RuleCode)
|
||||
}
|
||||
return out, nil
|
||||
// eventDeadlineRuleRow is the package-private row shape used by
|
||||
// Calculate's SELECT against paliad.deadline_rules. Keeps optional
|
||||
// fields as pointers (nil = no composite alt-leg / no notes). rule_codes
|
||||
// is pq.StringArray so the text[] column scans cleanly; Pipeline-C
|
||||
// rules without junction rows have a NULL column and end up with a nil
|
||||
// slice (treated as "no codes").
|
||||
type eventDeadlineRuleRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
SequenceOrder int `db:"sequence_order"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
DurationValue int `db:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit"`
|
||||
Timing string `db:"timing"`
|
||||
DeadlineNotes *string `db:"deadline_notes"`
|
||||
DeadlineNotesEn *string `db:"deadline_notes_en"`
|
||||
AltDurationValue *int `db:"alt_duration_value"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit"`
|
||||
CombineOp *string `db:"combine_op"`
|
||||
RuleCodes pq.StringArray `db:"rule_codes"`
|
||||
}
|
||||
|
||||
@@ -140,18 +140,25 @@ func TestComposite_R198_LongerLegWins(t *testing.T) {
|
||||
|
||||
// TestEventDeadlineService_Calculate_Parity is the LOAD-BEARING assertion
|
||||
// for Phase 3 Slice 3 (t-paliad-184). For every distinct trigger_event_id
|
||||
// in paliad.event_deadlines, it calls EventDeadlineService.Calculate (now
|
||||
// delegating to FristenrechnerService.calculateByTriggerEvent) AND
|
||||
// independently computes the same dates via the legacy applyDuration
|
||||
// helper directly against event_deadlines. Any divergence — date,
|
||||
// composite-flag, rule_codes — signals a Pipeline-C regression that
|
||||
// "Was kommt nach…" users would see in production.
|
||||
// in the Pipeline-C corpus, it calls EventDeadlineService.Calculate (now
|
||||
// fully delegating to FristenrechnerService.calculateByTriggerEvent) AND
|
||||
// independently computes the same dates via the package-level
|
||||
// applyDuration helper against the same deadline_rules source rows. Any
|
||||
// divergence — date, composite-flag, rule_codes — signals a Pipeline-C
|
||||
// regression that "Was kommt nach…" users would see in production.
|
||||
//
|
||||
// Why this matters: design §3.C + §3.2 cutover-ordering invariant 1 says
|
||||
// "additive schema lands first" and invariant 3 says "service rewrite
|
||||
// before drops". Slice 3 is the first slice where the unified backend
|
||||
// becomes the live serving path for event-driven deadlines. If parity
|
||||
// breaks here, every downstream slice rests on a regressed foundation.
|
||||
// Phase 3 Slice 9 follow-up A (t-paliad-199): mig 092 dropped
|
||||
// paliad.event_deadlines + paliad.event_deadline_rule_codes. The test
|
||||
// source query now reads from paliad.deadline_rules WHERE
|
||||
// trigger_event_id IS NOT NULL — the unified row set the service
|
||||
// reads. The independent computation is still meaningful: it bypasses
|
||||
// FristenrechnerService entirely and re-runs the package-level
|
||||
// applyDuration math against the raw column values, so any future
|
||||
// regression in the calculator's wrapping logic surfaces here.
|
||||
//
|
||||
// Field mapping (post-mig-092): name_en → Title, name → TitleDE,
|
||||
// (sequence_order - 1000) → ID (legacy event_deadlines.id semantic via
|
||||
// mig 085's sequence_order = 1000 + ed.id convention).
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
|
||||
@@ -177,18 +184,19 @@ func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
|
||||
svc := NewEventDeadlineService(pool, NewDeadlineCalculator(holidays), holidays, courts, fristen)
|
||||
|
||||
// Distinct trigger_event_id values for which we have at least one
|
||||
// active deadline in event_deadlines. The Slice 1 / Slice 2 / Slice 3
|
||||
// chain doesn't touch event_deadlines, so this set is stable.
|
||||
// active Pipeline-C rule. Mig 085 moved 77 active rows from
|
||||
// event_deadlines into deadline_rules with trigger_event_id IS NOT
|
||||
// NULL, so the set is stable across Slice 9 + follow-up A.
|
||||
var triggerIDs []int64
|
||||
if err := pool.SelectContext(ctx, &triggerIDs,
|
||||
`SELECT DISTINCT trigger_event_id
|
||||
FROM paliad.event_deadlines
|
||||
WHERE is_active = true
|
||||
FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id IS NOT NULL AND is_active = true
|
||||
ORDER BY trigger_event_id`); err != nil {
|
||||
t.Fatalf("list trigger ids: %v", err)
|
||||
}
|
||||
if len(triggerIDs) == 0 {
|
||||
t.Fatal("no event_deadlines rows — pipeline C corpus missing")
|
||||
t.Fatal("no Pipeline-C rules — corpus missing")
|
||||
}
|
||||
|
||||
// Reference date — arbitrary working day so weekend rollover noise is
|
||||
@@ -201,6 +209,9 @@ func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
|
||||
t.Fatalf("default court regime: %v", err)
|
||||
}
|
||||
|
||||
// Source-row shape mirrors EventDeadlineResult's columns so the
|
||||
// comparison is direct. ID derives from sequence_order via the
|
||||
// mig 085 convention; the post-mig-092 service does the same.
|
||||
type srcRow struct {
|
||||
ID int64 `db:"id"`
|
||||
Title string `db:"title"`
|
||||
@@ -223,11 +234,15 @@ func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
|
||||
|
||||
var src []srcRow
|
||||
if err := pool.SelectContext(ctx, &src,
|
||||
`SELECT id, title, title_de, duration_value, duration_unit, timing,
|
||||
`SELECT (sequence_order - 1000) AS id,
|
||||
name_en AS title,
|
||||
name AS title_de,
|
||||
duration_value, duration_unit,
|
||||
COALESCE(timing, 'after') AS timing,
|
||||
alt_duration_value, alt_duration_unit, combine_op
|
||||
FROM paliad.event_deadlines
|
||||
FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id = $1 AND is_active = true
|
||||
ORDER BY id`, tid); err != nil {
|
||||
ORDER BY sequence_order`, tid); err != nil {
|
||||
t.Fatalf("trigger=%d load source: %v", tid, err)
|
||||
}
|
||||
|
||||
@@ -236,10 +251,9 @@ func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort both by ID — Calculate's source SELECT also ORDER BY id, so
|
||||
// after we look up the source row for each result we can compare
|
||||
// positionally. (The unified path returns rows in sequence_order =
|
||||
// 1000 + ed.id which is identical ordering.)
|
||||
// Sort both by ID — the source SELECT ORDER BYs sequence_order
|
||||
// and we derive ID = sequence_order - 1000, so positional
|
||||
// comparison after the sort is exact.
|
||||
sort.Slice(resp.Deadlines, func(i, j int) bool {
|
||||
return resp.Deadlines[i].ID < resp.Deadlines[j].ID
|
||||
})
|
||||
|
||||
@@ -109,7 +109,7 @@ func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInp
|
||||
continue
|
||||
}
|
||||
|
||||
gateMet := evalConditionExpr([]byte(r.ConditionExpr), []string(r.ConditionFlag), flagSet)
|
||||
gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet)
|
||||
if !gateMet && r.AltDurationValue == nil {
|
||||
continue
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInp
|
||||
// FristenrechnerService.Calculate uses applies here.
|
||||
durationValue := r.DurationValue
|
||||
durationUnit := r.DurationUnit
|
||||
if r.CombineOp == nil && gateMet && len(r.ConditionFlag) > 0 && r.AltDurationValue != nil {
|
||||
if r.CombineOp == nil && gateMet && hasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
|
||||
durationValue = *r.AltDurationValue
|
||||
if r.AltDurationUnit != nil {
|
||||
durationUnit = *r.AltDurationUnit
|
||||
@@ -151,17 +151,15 @@ func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInp
|
||||
}
|
||||
}
|
||||
|
||||
// Slice 8 wire-shape swap: emit Priority + ConditionExpr directly;
|
||||
// keep the legacy pair populated for one release.
|
||||
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
|
||||
// Slice 9 (t-paliad-195): Priority is the canonical wire signal.
|
||||
// Legacy IsMandatory/IsOptional fields dropped from UIDeadline
|
||||
// along with the underlying column drop.
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
IsMandatory: wireMand,
|
||||
IsOptional: wireOpt,
|
||||
IsCourtSet: r.IsCourtSet,
|
||||
DueDate: adjusted.Format("2006-01-02"),
|
||||
OriginalDate: origDate.Format("2006-01-02"),
|
||||
|
||||
@@ -36,12 +36,11 @@ func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayServi
|
||||
// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
|
||||
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
|
||||
//
|
||||
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: Priority +
|
||||
// ConditionExpr are the new authoritative fields the frontend should
|
||||
// read. IsMandatory + IsOptional + (the legacy condition_flag, not
|
||||
// emitted directly on UIDeadline today) stay populated via
|
||||
// wireFlagsFromPriority for one release so the existing frontend keeps
|
||||
// working while the cutover lands. Slice 9 drops the legacy fields.
|
||||
// Phase 3 Slice 9 (t-paliad-195) dropped the legacy IsMandatory +
|
||||
// IsOptional fields — Priority is the canonical wire signal. The
|
||||
// frontend reads priorityRendering(d) which since Slice 8 has
|
||||
// priority as the primary input; Slice 9 removes the legacy fallback
|
||||
// branch from the frontend too.
|
||||
type UIDeadline struct {
|
||||
RuleID string `json:"ruleId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
@@ -49,15 +48,10 @@ type UIDeadline struct {
|
||||
NameEN string `json:"nameEN"`
|
||||
Party string `json:"party"`
|
||||
// Priority is the 4-way enum the rule-editor + save-modal logic
|
||||
// reads after Slice 8: 'mandatory' | 'recommended' | 'optional' |
|
||||
// 'informational'. Informational rules render as notice cards
|
||||
// (no save button, no checkbox) — the visible UX win of Phase 3
|
||||
// on today's 18 F/F rules.
|
||||
// reads: 'mandatory' | 'recommended' | 'optional' | 'informational'.
|
||||
// Informational rules render as notice cards (no save button, no
|
||||
// checkbox) — the visible UX win of Phase 3 on today's F/F rules.
|
||||
Priority string `json:"priority"`
|
||||
// IsMandatory is the LEGACY field derived from Priority via
|
||||
// wireFlagsFromPriority. Kept populated for one release so the
|
||||
// pre-Slice-8 frontend keeps working; Slice 9 drops it.
|
||||
IsMandatory bool `json:"isMandatory"`
|
||||
RuleRef string `json:"ruleRef"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
@@ -74,9 +68,6 @@ type UIDeadline struct {
|
||||
// the rule is unconditional. Frontend reads this to render the
|
||||
// "Mit Nichtigkeitswiderklage" hint chips.
|
||||
ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"`
|
||||
// IsOptional is the LEGACY field derived from Priority via
|
||||
// wireFlagsFromPriority. Kept for one release; Slice 9 drops it.
|
||||
IsOptional bool `json:"isOptional,omitempty"`
|
||||
// IsCourtSetIndirect is true when IsCourtSet is true because the
|
||||
// rule chains off a court-determined parent (e.g. RoP.151
|
||||
// Kostenentscheidung is "1 Monat ab Hauptentscheidung", and the
|
||||
@@ -263,32 +254,23 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
deadlines := make([]UIDeadline, 0, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb) with
|
||||
// fallback to condition_flag (legacy text[]) AND-semantics.
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb).
|
||||
// Suppression semantic preserved: when the gate fires false AND
|
||||
// no alt_* values exist, the rule is dropped from the timeline
|
||||
// entirely (purely conditional). When alt_* values exist, the
|
||||
// gate-false branch still renders, just without the alt-swap
|
||||
// (legacy "swap-on-flag" pattern, e.g. with_ccr).
|
||||
gateMet := evalConditionExpr([]byte(r.ConditionExpr), []string(r.ConditionFlag), flagSet)
|
||||
gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet)
|
||||
if !gateMet && r.AltDurationValue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: emit Priority +
|
||||
// ConditionExpr directly. wireFlagsFromPriority still populates
|
||||
// the legacy (IsMandatory, IsOptional) pair so the pre-Slice-8
|
||||
// frontend keeps working. Slice 9 drops the legacy fields.
|
||||
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
|
||||
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
IsMandatory: wireMand,
|
||||
IsOptional: wireOpt,
|
||||
}
|
||||
if r.Code != nil {
|
||||
d.Code = *r.Code
|
||||
@@ -488,7 +470,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
if r.Timing != nil {
|
||||
timing = *r.Timing
|
||||
}
|
||||
if r.CombineOp == nil && gateMet && len(r.ConditionFlag) > 0 && r.AltDurationValue != nil {
|
||||
if r.CombineOp == nil && gateMet && hasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
|
||||
durationValue = *r.AltDurationValue
|
||||
if r.AltDurationUnit != nil {
|
||||
durationUnit = *r.AltDurationUnit
|
||||
@@ -662,6 +644,7 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mandWire, _ := wireFlagsFromPriority(rule.Priority)
|
||||
out := &RuleCalculation{
|
||||
Rule: RuleCalculationRule{
|
||||
ID: rule.ID.String(),
|
||||
@@ -669,7 +652,7 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
NameEN: rule.NameEN,
|
||||
DurationValue: rule.DurationValue,
|
||||
DurationUnit: rule.DurationUnit,
|
||||
IsMandatory: rule.IsMandatory,
|
||||
IsMandatory: mandWire,
|
||||
},
|
||||
Proceeding: RuleCalculationProceeding{
|
||||
Code: pt.Code,
|
||||
@@ -697,9 +680,10 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
if rule.DeadlineNotesEn != nil {
|
||||
out.Rule.NotesEN = *rule.DeadlineNotesEn
|
||||
}
|
||||
if len(rule.ConditionFlag) > 0 {
|
||||
out.FlagsRequired = []string(rule.ConditionFlag)
|
||||
}
|
||||
// Slice 9 (t-paliad-195) replacement for the dropped condition_flag
|
||||
// text[] enumeration: walk the jsonb gate to pull out flag-leaf
|
||||
// names. Returns nil on an unconditional rule.
|
||||
out.FlagsRequired = extractFlagsFromExpr(rule.ConditionExpr)
|
||||
|
||||
// Court-determined: no calculable date.
|
||||
if rule.IsCourtSet {
|
||||
@@ -716,9 +700,9 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
}
|
||||
durationValue := rule.DurationValue
|
||||
durationUnit := rule.DurationUnit
|
||||
gateMet := evalConditionExpr([]byte(rule.ConditionExpr), []string(rule.ConditionFlag), flagSet)
|
||||
if gateMet && len(rule.ConditionFlag) > 0 {
|
||||
out.FlagsApplied = []string(rule.ConditionFlag)
|
||||
gateMet := evalConditionExpr([]byte(rule.ConditionExpr), flagSet)
|
||||
if gateMet && hasConditionExpr(rule.ConditionExpr) {
|
||||
out.FlagsApplied = out.FlagsRequired
|
||||
if rule.AltDurationValue != nil {
|
||||
durationValue = *rule.AltDurationValue
|
||||
}
|
||||
@@ -890,16 +874,13 @@ func allFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
// JSON → true (defensive: the rule still renders, the lawyer sees
|
||||
// it even if the gate is broken).
|
||||
//
|
||||
// Fallback: when expr is NULL but the legacy condition_flag text[] is
|
||||
// set, evaluate AND-semantics over condition_flag — preserves
|
||||
// pre-Slice-2 behaviour for the (defensive, shouldn't-happen) case
|
||||
// where mig 084 missed a row.
|
||||
func evalConditionExpr(expr []byte, conditionFlag []string, flags map[string]struct{}) bool {
|
||||
// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag
|
||||
// text[] column; the fallback that AND'd over it is gone. Any future
|
||||
// row needing array-of-flags semantics writes the equivalent
|
||||
// {"op":"and","args":[{"flag":"<a>"},...]} jsonb directly.
|
||||
func evalConditionExpr(expr []byte, flags map[string]struct{}) bool {
|
||||
if len(expr) == 0 || string(expr) == "null" {
|
||||
if len(conditionFlag) == 0 {
|
||||
return true
|
||||
}
|
||||
return allFlagsSet(conditionFlag, flags)
|
||||
return true
|
||||
}
|
||||
return evalConditionExprNode(expr, flags)
|
||||
}
|
||||
@@ -952,6 +933,59 @@ func evalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// hasConditionExpr returns true when the rule carries a non-empty,
|
||||
// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the
|
||||
// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the
|
||||
// flag-keyed alt-swap branch. Same intent: "this rule has a gate;
|
||||
// when the gate flips to met, swap to alt".
|
||||
func hasConditionExpr(expr models.NullableJSON) bool {
|
||||
if len(expr) == 0 {
|
||||
return false
|
||||
}
|
||||
s := string(expr)
|
||||
return s != "null" && s != "{}"
|
||||
}
|
||||
|
||||
// extractFlagsFromExpr walks the jsonb gate and returns the unique
|
||||
// flag names referenced as {"flag":"<name>"} leaves. Used by
|
||||
// CalculateRule's response (FlagsRequired) so the result-card calc
|
||||
// panel can render flag checkboxes for each gate input. Replaces the
|
||||
// dropped condition_flag text[] enumeration. Returns nil on a NULL
|
||||
// expression or one that contains no flag leaves.
|
||||
func extractFlagsFromExpr(expr models.NullableJSON) []string {
|
||||
if !hasConditionExpr(expr) {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
walkFlagLeaves([]byte(expr), seen)
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for f := range seen {
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func walkFlagLeaves(raw []byte, into map[string]struct{}) {
|
||||
var node struct {
|
||||
Flag string `json:"flag"`
|
||||
Op string `json:"op"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
return
|
||||
}
|
||||
if node.Flag != "" {
|
||||
into[node.Flag] = struct{}{}
|
||||
return
|
||||
}
|
||||
for _, a := range node.Args {
|
||||
walkFlagLeaves(a, into)
|
||||
}
|
||||
}
|
||||
|
||||
// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional)
|
||||
// pair from the unified priority enum so the wire shape stays
|
||||
// pixel-identical through Slice 4. Slice 8 will swap the wire to
|
||||
@@ -1156,18 +1190,17 @@ func (s *FristenrechnerService) calculateByTriggerEvent(
|
||||
}
|
||||
}
|
||||
|
||||
// Slice 8 wire-shape swap: trigger-event path also emits Priority
|
||||
// + ConditionExpr directly. Pipeline-C rules default Priority=
|
||||
// 'mandatory' (mig 085) so the legacy pair (T, F) holds.
|
||||
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
|
||||
// Slice 9 (t-paliad-195) wire-shape cleanup: trigger-event
|
||||
// path emits Priority + ConditionExpr directly. The legacy
|
||||
// IsMandatory/IsOptional pair was retired with the column
|
||||
// drop; frontend reads priorityRendering(d) which now branches
|
||||
// on priority alone.
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
Priority: r.Priority,
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
IsMandatory: wireMand,
|
||||
IsOptional: wireOpt,
|
||||
DueDate: picked.Format("2006-01-02"),
|
||||
OriginalDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdj,
|
||||
|
||||
@@ -199,55 +199,45 @@ func TestEvalConditionExpr(t *testing.T) {
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
expr string
|
||||
legacyFlag []string
|
||||
flags map[string]struct{}
|
||||
want bool
|
||||
name string
|
||||
expr string
|
||||
flags map[string]struct{}
|
||||
want bool
|
||||
}{
|
||||
// NULL expr — fall back to legacy condition_flag AND-semantics.
|
||||
{"NULL expr, no legacy flag → unconditional",
|
||||
"", nil, mkSet(), true},
|
||||
{"NULL expr, legacy flag absent → suppressed",
|
||||
"", []string{"with_ccr"}, mkSet(), false},
|
||||
{"NULL expr, legacy flag present → true",
|
||||
"", []string{"with_ccr"}, mkSet("with_ccr"), true},
|
||||
{"NULL expr, two legacy flags both present → true",
|
||||
"", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend"), true},
|
||||
{"NULL expr, two legacy flags only one present → false",
|
||||
"", []string{"with_ccr", "with_amend"}, mkSet("with_ccr"), false},
|
||||
// NULL / empty / "null" expr → unconditional. Slice 9 removed
|
||||
// the legacy condition_flag fallback that used to make this
|
||||
// branch return false on flags-not-met — the column is gone.
|
||||
{"empty expr → unconditional", "", mkSet(), true},
|
||||
{"empty expr with flags set → unconditional", "", mkSet("with_ccr"), true},
|
||||
{"literal null → unconditional", "null", mkSet(), true},
|
||||
|
||||
// Single-flag leaf (mig 084 unwrapped form for [single]).
|
||||
{"single-flag leaf present → true",
|
||||
`{"flag":"with_ccr"}`, nil, mkSet("with_ccr"), true},
|
||||
{"single-flag leaf absent → false",
|
||||
`{"flag":"with_ccr"}`, nil, mkSet("with_amend"), false},
|
||||
{"single-flag leaf present → true", `{"flag":"with_ccr"}`, mkSet("with_ccr"), true},
|
||||
{"single-flag leaf absent → false", `{"flag":"with_ccr"}`, mkSet("with_amend"), false},
|
||||
|
||||
// AND.
|
||||
{"and(a, b) both present → true",
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
nil, mkSet("with_ccr", "with_amend"), true},
|
||||
mkSet("with_ccr", "with_amend"), true},
|
||||
{"and(a, b) one absent → false",
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
nil, mkSet("with_ccr"), false},
|
||||
{"and() empty args → true (vacuously)",
|
||||
`{"op":"and","args":[]}`, nil, mkSet(), true},
|
||||
mkSet("with_ccr"), false},
|
||||
{"and() empty args → true (vacuously)", `{"op":"and","args":[]}`, mkSet(), true},
|
||||
|
||||
// OR.
|
||||
{"or(a, b) any present → true",
|
||||
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
nil, mkSet("with_amend"), true},
|
||||
mkSet("with_amend"), true},
|
||||
{"or(a, b) none present → false",
|
||||
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
nil, mkSet("with_cci"), false},
|
||||
{"or() empty args → false (vacuously)",
|
||||
`{"op":"or","args":[]}`, nil, mkSet(), false},
|
||||
mkSet("with_cci"), false},
|
||||
{"or() empty args → false (vacuously)", `{"op":"or","args":[]}`, mkSet(), false},
|
||||
|
||||
// NOT.
|
||||
{"not(flag) absent → true",
|
||||
`{"op":"not","args":[{"flag":"with_ccr"}]}`, nil, mkSet(), true},
|
||||
`{"op":"not","args":[{"flag":"with_ccr"}]}`, mkSet(), true},
|
||||
{"not(flag) present → false",
|
||||
`{"op":"not","args":[{"flag":"with_ccr"}]}`, nil, mkSet("with_ccr"), false},
|
||||
`{"op":"not","args":[{"flag":"with_ccr"}]}`, mkSet("with_ccr"), false},
|
||||
|
||||
// Nested.
|
||||
{"and(or(a, b), not(c)) all conditions met → true",
|
||||
@@ -255,29 +245,26 @@ func TestEvalConditionExpr(t *testing.T) {
|
||||
{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
|
||||
{"op":"not","args":[{"flag":"expedited"}]}
|
||||
]}`,
|
||||
nil, mkSet("with_amend"), true},
|
||||
mkSet("with_amend"), true},
|
||||
{"and(or(a, b), not(c)) NOT condition fails → false",
|
||||
`{"op":"and","args":[
|
||||
{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
|
||||
{"op":"not","args":[{"flag":"expedited"}]}
|
||||
]}`,
|
||||
nil, mkSet("with_amend", "expedited"), false},
|
||||
mkSet("with_amend", "expedited"), false},
|
||||
|
||||
// Malformed → defensive true (rule still renders).
|
||||
{"malformed JSON → true (defensive)",
|
||||
`{"op":"bro`, nil, mkSet(), true},
|
||||
{"unknown op → true (forward-compat)",
|
||||
`{"op":"xor","args":[{"flag":"with_ccr"}]}`, nil, mkSet(), true},
|
||||
{"not with two args → true (malformed NOT)",
|
||||
`{"op":"not","args":[{"flag":"a"},{"flag":"b"}]}`, nil, mkSet(), true},
|
||||
{"malformed JSON → true (defensive)", `{"op":"bro`, mkSet(), true},
|
||||
{"unknown op → true (forward-compat)", `{"op":"xor","args":[{"flag":"with_ccr"}]}`, mkSet(), true},
|
||||
{"not with two args → true (malformed NOT)", `{"op":"not","args":[{"flag":"a"},{"flag":"b"}]}`, mkSet(), true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := evalConditionExpr([]byte(tc.expr), tc.legacyFlag, tc.flags)
|
||||
got := evalConditionExpr([]byte(tc.expr), tc.flags)
|
||||
if got != tc.want {
|
||||
t.Errorf("evalConditionExpr(%q, %v, flags) = %v, want %v",
|
||||
tc.expr, tc.legacyFlag, got, tc.want)
|
||||
t.Errorf("evalConditionExpr(%q, flags) = %v, want %v",
|
||||
tc.expr, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -445,23 +432,6 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
|
||||
if !allowed[d.Priority] {
|
||||
t.Errorf("rule %s: priority=%q not in unified enum", d.Code, d.Priority)
|
||||
}
|
||||
// Legacy-field invariant: wireFlagsFromPriority round-trip.
|
||||
// 'mandatory' → (T, F); 'optional' → (T, T); 'recommended' / 'informational' → (F, F).
|
||||
switch d.Priority {
|
||||
case "mandatory":
|
||||
if !d.IsMandatory || d.IsOptional {
|
||||
t.Errorf("rule %s: mandatory should map to (T,F), got (%v,%v)", d.Code, d.IsMandatory, d.IsOptional)
|
||||
}
|
||||
case "optional":
|
||||
if !d.IsMandatory || !d.IsOptional {
|
||||
t.Errorf("rule %s: optional should map to (T,T), got (%v,%v)", d.Code, d.IsMandatory, d.IsOptional)
|
||||
}
|
||||
case "recommended", "informational":
|
||||
if d.IsMandatory || d.IsOptional {
|
||||
t.Errorf("rule %s: %s should map to (F,F), got (%v,%v)",
|
||||
d.Code, d.Priority, d.IsMandatory, d.IsOptional)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At least one rule should carry a populated conditionExpr (the
|
||||
|
||||
87
internal/services/proceeding_mapping.go
Normal file
87
internal/services/proceeding_mapping.go
Normal file
@@ -0,0 +1,87 @@
|
||||
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
|
||||
// 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.
|
||||
|
||||
// MapLitigationToFristenrechner returns the fristenrechner code +
|
||||
// condition flags implied by a (litigationCode, jurisdiction) pair.
|
||||
//
|
||||
// Inputs are case-sensitive — pass the canonical upper-snake form
|
||||
// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous
|
||||
// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero
|
||||
// fristenrechner code; callers should treat that as "no narrowing"
|
||||
// and leave the cascade wide-open rather than auto-pick.
|
||||
//
|
||||
// Condition flags are returned as a slice so callers can apply them
|
||||
// alongside the fristenrechner code (CCR+UPC → UPC_INF + with_ccr,
|
||||
// AMD+UPC → UPC_INF + 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
|
||||
case "DE":
|
||||
return "DE_INF", nil, true
|
||||
}
|
||||
case "REV":
|
||||
switch jurisdiction {
|
||||
case "UPC":
|
||||
return "UPC_REV", nil, true
|
||||
case "DE":
|
||||
return "DE_NULL", nil, true
|
||||
}
|
||||
case "CCR":
|
||||
// Counterclaim revocation — UPC fold-in is structural (the
|
||||
// counterclaim lives inside an UPC_INF 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
|
||||
case "DE":
|
||||
return "DE_NULL", nil, true
|
||||
}
|
||||
case "AMD":
|
||||
// Amendment-application bundled into UPC_INF via with_amend.
|
||||
// No DE / EPA / DPMA analogue today.
|
||||
if jurisdiction == "UPC" {
|
||||
return "UPC_INF", []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.
|
||||
if jurisdiction == "UPC" {
|
||||
return "UPC_APP", nil, true
|
||||
}
|
||||
case "APM":
|
||||
// Preliminary injunction / urgency procedure — UPC-only
|
||||
// concept in the fristenrechner taxonomy.
|
||||
if jurisdiction == "UPC" {
|
||||
return "UPC_PI", nil, true
|
||||
}
|
||||
case "OPP":
|
||||
// Opposition — primarily EPA. DPMA has DPMA_OPP but it
|
||||
// doesn't surface from the litigation vocabulary today.
|
||||
if jurisdiction == "EPA" {
|
||||
return "EPA_OPP", nil, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
54
internal/services/proceeding_mapping_test.go
Normal file
54
internal/services/proceeding_mapping_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMapLitigationToFristenrechner(t *testing.T) {
|
||||
type tc struct {
|
||||
litigation, jurisdiction string
|
||||
wantCode string
|
||||
wantFlags []string
|
||||
wantOK bool
|
||||
}
|
||||
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},
|
||||
// DE first-instance / Nichtigkeit mappings.
|
||||
{"INF", "DE", "DE_INF", nil, true},
|
||||
{"REV", "DE", "DE_NULL", nil, true},
|
||||
{"CCR", "DE", "DE_NULL", nil, true},
|
||||
// EPA opposition.
|
||||
{"OPP", "EPA", "EPA_OPP", nil, true},
|
||||
// Ambiguous: APP+DE has both OLG and BGH analogues; project
|
||||
// model can't disambiguate, so degrade.
|
||||
{"APP", "DE", "", nil, false},
|
||||
// No analogue: ZPO_CIVIL → nothing in fristenrechner.
|
||||
{"ZPO_CIVIL", "DE", "", nil, false},
|
||||
// AMD only fires on UPC; DE has no analogue.
|
||||
{"AMD", "DE", "", nil, false},
|
||||
// APM only fires on UPC.
|
||||
{"APM", "EPA", "", nil, false},
|
||||
// Unknown codes / jurisdictions → ok=false.
|
||||
{"XXX", "UPC", "", nil, false},
|
||||
{"INF", "ZZZ", "", nil, false},
|
||||
{"", "", "", nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
gotCode, gotFlags, gotOK := MapLitigationToFristenrechner(c.litigation, c.jurisdiction)
|
||||
if gotCode != c.wantCode || gotOK != c.wantOK || !reflect.DeepEqual(gotFlags, c.wantFlags) {
|
||||
t.Errorf("MapLitigationToFristenrechner(%q, %q) = (%q, %v, %v); want (%q, %v, %v)",
|
||||
c.litigation, c.jurisdiction,
|
||||
gotCode, gotFlags, gotOK,
|
||||
c.wantCode, c.wantFlags, c.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -21,15 +21,22 @@ 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.
|
||||
//
|
||||
// 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) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
@@ -63,14 +70,22 @@ 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,
|
||||
@@ -104,14 +119,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 +156,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -327,13 +327,15 @@ func TestExpandCrossProceedingSpawns(t *testing.T) {
|
||||
t.Fatalf("set audit_reason: %v", err)
|
||||
}
|
||||
id := uuid.New()
|
||||
// Slice 9 (t-paliad-195) dropped is_mandatory / is_optional;
|
||||
// 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,
|
||||
timing, is_mandatory, is_optional, is_court_set, is_spawn,
|
||||
timing, is_court_set, is_spawn,
|
||||
spawn_proceeding_type_id, sequence_order, is_active, priority,
|
||||
lifecycle_state, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $3, $4, 0, 'days', 'after', true, false, false, $5, $6, $7,
|
||||
VALUES ($1, $2, $3, $3, $4, 0, 'days', 'after', false, $5, $6, $7,
|
||||
true, 'mandatory', 'published', now(), now())`,
|
||||
id, ptID, label, code, isSpawn, spawnTargetPT, sequenceOrder)
|
||||
if err != nil {
|
||||
|
||||
@@ -162,6 +162,10 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
// Slice 9 (t-paliad-195) dropped is_mandatory / is_optional /
|
||||
// condition_flag / condition_rule_id from the schema. The INSERT
|
||||
// here writes the live shape only — priority + condition_expr
|
||||
// + 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,
|
||||
@@ -171,7 +175,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
rule_code, legal_source, deadline_notes, deadline_notes_en,
|
||||
priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
condition_expr, sequence_order,
|
||||
is_mandatory, is_optional, is_active,
|
||||
is_active,
|
||||
lifecycle_state, draft_of, published_at,
|
||||
created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, $10,
|
||||
@@ -180,7 +184,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
$19, $20, $21, $22,
|
||||
$23, $24, $25, $26, $27,
|
||||
$28, $29,
|
||||
true, false, true,
|
||||
true,
|
||||
'draft', NULL, NULL,
|
||||
now(), now())`,
|
||||
id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.Code,
|
||||
@@ -288,8 +292,8 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
|
||||
rule_code, legal_source, deadline_notes, deadline_notes_en,
|
||||
priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
condition_expr, condition_flag, sequence_order,
|
||||
is_mandatory, is_optional, is_active,
|
||||
condition_expr, sequence_order,
|
||||
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,
|
||||
@@ -298,8 +302,8 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
|
||||
rule_code, legal_source, deadline_notes, deadline_notes_en,
|
||||
priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
condition_expr, condition_flag, sequence_order,
|
||||
is_mandatory, is_optional, is_active,
|
||||
condition_expr, sequence_order,
|
||||
is_active,
|
||||
'draft', $2, NULL,
|
||||
now(), now()
|
||||
FROM paliad.deadline_rules
|
||||
|
||||
@@ -260,17 +260,18 @@ func TestRuleEditorService_Preview(t *testing.T) {
|
||||
`SELECT set_config('paliad.audit_reason', 'slice 11a preview seed', true)`); err != nil {
|
||||
t.Fatalf("set audit reason: %v", err)
|
||||
}
|
||||
// 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,
|
||||
duration_value, duration_unit, timing,
|
||||
is_mandatory, is_optional, is_court_set, is_spawn,
|
||||
is_court_set, is_spawn,
|
||||
priority, lifecycle_state, is_active, sequence_order,
|
||||
published_at, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), $1, 'preview.root',
|
||||
'SLICE11A_PREVIEW_root', 'SLICE11A_PREVIEW_root_EN',
|
||||
30, 'days', 'after',
|
||||
true, false, false, false,
|
||||
false, false,
|
||||
'mandatory', 'published', true, 0,
|
||||
now(), now(), now())`, ptID); err != nil {
|
||||
t.Fatalf("seed published rule: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user