docs(t-paliad-136): Fristenrechner v4 inventor design
v4 addresses three concerns from m on 2026-05-05 in priority order: 1. Card-click → compute deadline → add-to-project (v3 cards were dead-ends). 2. Filter narrowing bug — slug → concept_id allow-list dropped per-leaf proceeding_type_code, so picking "UPC infringement opposing party" leaked DE/EPA/DPMA pills. Confirmed via DB query: 25+ leaves overbroad. 3. RoP-rigorous tree audit: 6 confirmed seed errors (Hinweisbeschluss DE_INF mismap, notice-of-defence-intention UPC_INF mismap, three cost-appeal notice-of-appeal mismaps, request-for-discretionary-review needs UPC_APP_ORDERS narrowing), plus reply-to-cross-appeal coverage gap and bescheid-mit-frist orphan. Plan splits into three independent phases (A: filter fix, no schema; B: card-click flow + new calculate-rule endpoint; C: taxonomy migration 052 without RAISE EXCEPTION coverage gates per last night's outage lesson). Inventor → coder gate held: no production code in this commit.
This commit is contained in:
459
docs/plans/unified-fristenrechner-v4.md
Normal file
459
docs/plans/unified-fristenrechner-v4.md
Normal file
@@ -0,0 +1,459 @@
|
||||
# Fristenrechner v4 — RoP-rigorous tree, working filter, card-click computes a deadline
|
||||
|
||||
**Task:** t-paliad-136
|
||||
**Branch:** mai/cronus/inventor-fristenrechner-v4-design
|
||||
**Status:** Inventor design — gated. No code changes in this shift.
|
||||
**Predecessors:** v3 (`unified-fristenrechner-v3.md`, t-paliad-133), B1-result-cards (t-paliad-134), pill ordering + dedup (t-paliad-134 v2)
|
||||
|
||||
m's three concerns from 2026-05-05 11:58–11:59, **in m's priority order**:
|
||||
|
||||
1. **Card-click does nothing.** Should expand into a calculation panel that takes a trigger date (default today), shows the resulting deadline (with t-119 adjustment-reason explainer + t-121 vacation-skip), and exposes an "Add to project" CTA that drops the deadline into an existing Akte. _Most important._
|
||||
2. **Filter narrowing is broken.** Picking "CMS-Eingang → Gegenseite → UPC Verletzung" still surfaces national submissions. Confirmed bug — see §2.
|
||||
3. **Decision tree must follow the RoP rigorously.** The seed (migration 049) was rapid first-pass; concept↔leaf mappings have errors. Audit + correction in §3.
|
||||
|
||||
The work splits into three independent migrations / phases (see §4) so we can ship the bug-fix without waiting on the taxonomy revision.
|
||||
|
||||
---
|
||||
|
||||
## 1 — Card-click → compute deadline → add to project
|
||||
|
||||
### 1.1 Why this is the headline feature
|
||||
|
||||
v3 shipped concept cards that visualise "which deadline applies in which forum" at every B1 leaf — a great discovery surface. But the cards are **terminal**. The user can read the pill ("Klageerwiderung · UPC RoP R.23(1) · 3 Monate"), and then they're stuck. To actually compute a date they have to switch back to Pathway A's Verfahrensablauf, click the matching proceeding button, type the date in step 2, and read the deadline out of the timeline.
|
||||
|
||||
That's the round-trip Pathway B was supposed to eliminate. The v2 calculator (CORE Pathway A) had **trigger-date → computed-deadline** as the only feature; the v3 cards lost that.
|
||||
|
||||
This phase brings it back, **scoped to the single rule the user clicked**.
|
||||
|
||||
### 1.2 UX spec — inline calc panel inside the card
|
||||
|
||||
When the user clicks a result card, the card expands inline (no modal, no page navigation). The expanded card has three logical zones, top-to-bottom:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ ▾ Klageerwiderung [ × schließen ] │
|
||||
│ Statement of Defence │
|
||||
│ │
|
||||
│ ┌──────────────── Pill picker (only if N>1) ──────────────┐ │
|
||||
│ │ ◉ UPC Verletzung · R.23(1) · 3 Mon · Beklagter │ │
|
||||
│ │ ○ DE Verletzung (LG) · §276 ZPO · 6 Wo · Beklagter │ │
|
||||
│ │ ○ EPA Einspruch · R.79(1) · 4 Mon · Inhaber │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────── Trigger + Flags ─────────────────────────┐ │
|
||||
│ │ Datum des auslösenden Ereignisses │ │
|
||||
│ │ [ 2026-05-05 ▼ ] │ │
|
||||
│ │ ☐ mit Nichtigkeitswiderklage (R.49.2.a) │ │
|
||||
│ │ ☐ mit Patentänderungsantrag │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────── Berechnete Frist ────────────────────────┐ │
|
||||
│ │ ► 04.08.2026 (3 Monate ab 05.05.2026) │ │
|
||||
│ │ ⚠ Verschoben vom 03.08.2026 wegen UPC-Sommerferien │ │
|
||||
│ │ (27.7.–28.8.) — fällt auf nächsten Werktag. │ │
|
||||
│ │ │ │
|
||||
│ │ [ 📌 Zu Akte hinzufügen ] │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
State transitions, all client-side:
|
||||
|
||||
| Action | Effect |
|
||||
|---|---|
|
||||
| Click a card row in `#fristen-b1-results` or `#fristen-search-results` | Card expands. Pill picker shows only if >1 pill survived narrowing. First pill auto-selected. Trigger date defaults to today. Calc fires immediately. |
|
||||
| Click another pill in the picker | Re-fire calc with the new pill's rule. Flag checkbox visibility re-derived from the new rule's `condition_flag`. |
|
||||
| Change trigger date | Debounce 200 ms (match B2 search debounce), re-fire calc. |
|
||||
| Toggle a flag checkbox | Re-fire calc immediately (no debounce — discrete event). |
|
||||
| Click "Zu Akte hinzufügen" | Open project picker (modal — reuse existing `frist-save-modal` from `client/fristenrechner.ts:332`). On submit, POST `/api/projects/{id}/deadlines/bulk` with a single payload row. Show inline success message inside the card with a link to `/deadlines?project_id=…`. |
|
||||
| Click "× schließen" or click the card header again | Collapse back to compact view. |
|
||||
|
||||
Only **one card at a time** can be expanded — opening a second card collapses the first. This keeps the page short and avoids confusion about which trigger date applies where.
|
||||
|
||||
### 1.3 Picking the right pill on multi-pill cards
|
||||
|
||||
After §2's narrowing fix lands, most leaves will have 1 pill per card. But some legitimately have several:
|
||||
|
||||
- "Frist verpasst → EPA": `wiedereinsetzung` (Art. 122) AND `weiterbehandlung` (Art. 121) — **different rules entirely.**
|
||||
- "Spätere Schriftsätze → Replik auf Erwiderung zur Nichtigkeitsw.": one pill per proceeding the rule applies in (UPC_INF only after fix).
|
||||
- "CMS-Eingang → Gericht → Hinweisbeschluss": `response-to-preliminary-opinion` in `DE_NULL` (the only correct mapping after audit) — exactly 1 pill.
|
||||
|
||||
Heuristic: if the card has **1 pill** after narrowing, skip the pill picker and use that pill directly. If the card has **2+ pills**, render a radio-chip row preselecting the highest-`proceeding_display_order` pill (most-frequent forum first, t-paliad-134 ordering rule).
|
||||
|
||||
### 1.4 The single-rule calculator endpoint
|
||||
|
||||
We **do not** want to call `POST /api/tools/fristenrechner` (which renders the entire proceeding timeline) when the user clicks a card. That payload is 5–15 kB; we only need one rule.
|
||||
|
||||
**New endpoint:** `POST /api/tools/fristenrechner/calculate-rule`
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"ruleId": "uuid", // either ruleId, OR
|
||||
"proceedingCode": "UPC_INF", // (proceedingCode + ruleLocalCode)
|
||||
"ruleLocalCode": "inf.sod",
|
||||
"triggerDate": "2026-05-05",
|
||||
"flags": ["with_ccr"] // optional; only flags applicable to the rule
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"rule": {
|
||||
"id": "uuid",
|
||||
"localCode": "inf.sod",
|
||||
"nameDE": "Klageerwiderung",
|
||||
"nameEN": "Statement of Defence",
|
||||
"ruleRef": "RoP.023",
|
||||
"legalSource": "UPC.RoP.23.1",
|
||||
"legalSourceDisplay": "UPC RoP R.23(1)",
|
||||
"durationValue": 3,
|
||||
"durationUnit": "months",
|
||||
"party": "defendant",
|
||||
"isCourtSet": false,
|
||||
"isMandatory": true
|
||||
},
|
||||
"proceeding": {
|
||||
"code": "UPC_INF",
|
||||
"nameDE": "Verletzungsverfahren",
|
||||
"nameEN": "Infringement Action"
|
||||
},
|
||||
"triggerDate": "2026-05-05",
|
||||
"originalDate": "2026-08-05",
|
||||
"dueDate": "2026-08-05",
|
||||
"wasAdjusted": false,
|
||||
"adjustmentReason": null
|
||||
}
|
||||
```
|
||||
|
||||
When the rule has `condition_flag` and the user supplies all of them AND `alt_duration_value` is set, the response uses the alt values (existing flag-swap semantics from `services/fristenrechner.go:368`). When the rule is `is_court_set` (party='court' OR event_type ∈ {hearing, decision, order}), `dueDate` is empty and `isCourtSet=true` — the UI shows "Gericht-bestimmt" instead of a calc panel and disables the "Add to project" CTA.
|
||||
|
||||
**Implementation:** `FristenrechnerService.CalculateRule(ctx, params) (*UIDeadline, error)` reusing the same `addDuration` + `HolidayService.AdjustForNonWorkingDaysWithReason` pipeline as `Calculate()` lines 397–404. Crucially it **does not walk the parent chain** — the trigger date is treated as the immediate parent's effective date, since that matches the user's mental model when clicking "Duplik": "I just received the Replik on date X, when's my Duplik due?"
|
||||
|
||||
For zero-duration rules (`is_court_set` waypoints, root events): respond with `dueDate=triggerDate` and `isCourtSet=true` for the court-set case. The UI handles both: court-set → no add-to-project CTA, "Add manually" hint instead.
|
||||
|
||||
### 1.5 Add-to-project — reuse the existing bulk endpoint
|
||||
|
||||
`POST /api/projects/{id}/deadlines/bulk` already exists and takes:
|
||||
```json
|
||||
{ "deadlines": [{ "title": "...", "rule_code": "...", "due_date": "...", "original_due_date": "...", "source": "fristenrechner", "notes": "..." }] }
|
||||
```
|
||||
|
||||
The card's "Zu Akte hinzufügen" sends a single-element array with the calc result. We extend the `source` enum: add `"fristenrechner_card"` so we can tell card-click adds apart from full-timeline adds in audit logs (one-line addition to whatever validates the source field today).
|
||||
|
||||
### 1.6 Why no "auto-add to project on card click"?
|
||||
|
||||
m's wording: "should allow adding that deadline to an existing proceeding" — adding is the explicit step, not the click itself. The click computes; the user reviews the date and adjustment-reason chip; only then do they decide whether the date actually goes into a real Akte. This matters because:
|
||||
|
||||
- Vacation-skip in either direction can move a date by ~28 days; users want to **see** the skip before committing.
|
||||
- The trigger date may be wrong (user typed it, or the matter has multiple receipt dates and they need to pick the right one).
|
||||
- The flag combinations alter the duration — the user may need to flip a flag once they remember "ah, this is the with_ccr case".
|
||||
|
||||
Computing inline is free (single SQL hit + one in-memory holidays scan). Persisting is consequential. Keep them separate.
|
||||
|
||||
---
|
||||
|
||||
## 2 — Filter narrowing bug — diagnosis & fix
|
||||
|
||||
### 2.1 m's repro
|
||||
|
||||
> "I chose 'CMS receipt' from opposing party UPC infringement and it still shows national submissions."
|
||||
|
||||
URL: `/tools/fristenrechner?path=b&mode=tree&b1=cms-eingang.gegenseite.upc-inf` (or the deeper `…upc-inf.klageerwiderung-mit-ccr` etc.).
|
||||
|
||||
Expected: only UPC_INF proceeding pills in result cards.
|
||||
Actual: cards show pills for DE_INF, DE_NULL, DPMA_OPP, EPA_OPP, UPC_DAMAGES, UPC_DISCOVERY, UPC_PI and UPC_REV alongside UPC_INF — i.e. every proceeding where the underlying concept (e.g. `statement-of-defence`) has a rule.
|
||||
|
||||
### 2.2 Root cause
|
||||
|
||||
The fix-needed code path lives in two places:
|
||||
|
||||
**`internal/services/event_category_service.go:194 ConceptIDsForSlug`** — collapses the `ConceptsForSlug` `(concept_id, proceeding_type_code)` tuple list down to a flat slice of concept IDs by deduplicating and **discarding the proceeding code**:
|
||||
|
||||
```go
|
||||
for _, r := range rows {
|
||||
if seen[r.ConceptID] { continue }
|
||||
seen[r.ConceptID] = true
|
||||
out = append(out, r.ConceptID)
|
||||
}
|
||||
```
|
||||
|
||||
**`internal/services/deadline_search_service.go:382 + 533 + 466`** — both `browseRanks`, `loadPills` and the `rankConcepts` matched-CTE filter the matview by `s.concept_id = ANY($N::uuid[])` only. There is no per-(concept × proc) constraint anywhere downstream of `ConceptIDsForSlug`.
|
||||
|
||||
So when a leaf maps `statement-of-defence | UPC_INF` in the `event_category_concepts` junction, the search service:
|
||||
|
||||
1. Resolves slug → concept_ids: `[id-of-statement-of-defence, id-of-reply-to-defence, …]`. Drops `UPC_INF`.
|
||||
2. Loads from matview every row where `concept_id` matches → all 9 proceedings of `statement-of-defence`, since the matview row exists for every (concept × rule) combo across the corpus (matview 047).
|
||||
3. Renders 9 pills under one card.
|
||||
|
||||
Reproduced live (`cms-eingang.gegenseite.upc-inf` subtree):
|
||||
|
||||
| concept | junction proc | matview procs returned |
|
||||
|---|---|---|
|
||||
| `statement-of-defence` | UPC_INF | `DE_INF, DE_NULL, DPMA_OPP, EPA_OPP, UPC_DAMAGES, UPC_DISCOVERY, UPC_INF, UPC_PI, UPC_REV` |
|
||||
| `rejoinder` | UPC_INF | `DE_INF, DE_NULL, UPC_DAMAGES, UPC_DISCOVERY, UPC_INF, UPC_REV` |
|
||||
| `reply-to-defence` | UPC_INF | `DE_INF, DE_NULL, UPC_DAMAGES, UPC_DISCOVERY, UPC_INF, UPC_REV` |
|
||||
| `notice-of-defence-intention` | UPC_INF | `DE_INF` (the junction maps to a non-existent UPC rule — see §3) |
|
||||
| `defence-to-counterclaim-for-revocation` | UPC_INF | `UPC_INF` (correct by coincidence — concept only exists in UPC_INF) |
|
||||
| `cross-appeal` | UPC_APP | `DE_INF_OLG, UPC_APP, UPC_APP_ORDERS` |
|
||||
| `response-to-appeal` | UPC_APP | `DE_INF_BGH, DE_INF_OLG, DE_NULL_BGH, EPA_APP, UPC_APP` |
|
||||
|
||||
Same mis-narrowing applies to **every leaf with a non-NULL `proceeding_type_code` in the junction** — 49 of them in the current seed. Confirmed by counting how many leaves have a junction-proc-code that the matview returns >1 proceeding for: at least 25 leaves are over-broad today, and several more have the inverse problem (junction maps to a proc that has no rule for that concept — silently dropped to no pill).
|
||||
|
||||
Of m's diagnostic options (a)/(b)/(c)/(d): **(b)** is the bug — the leaf-set is computed but the junction's `proceeding_type_code` constraint is dropped between `ConceptsForSlug` and `loadPills`. The frontend (c) is fine; the recursive CTE (a) is fine; (d) is not the cause.
|
||||
|
||||
### 2.3 Fix shape
|
||||
|
||||
Carry `(concept_id, proceeding_type_code)` as a tuple set, not as two independent lists. Tuple semantics:
|
||||
|
||||
- `(c, NULL)` in junction = "all proceeding contexts of this concept apply at this leaf" (used by cross-cutting concepts like `wiedereinsetzung`, `weiterbehandlung`, `versaeumnisurteil-einspruch` — they aren't tied to a specific proceeding).
|
||||
- `(c, X)` in junction = "ONLY proceeding X applies for concept c at this leaf".
|
||||
- Trigger pills (`kind='trigger'`) bypass the proc constraint by design (cross-cutting).
|
||||
|
||||
The matview filter becomes:
|
||||
|
||||
```sql
|
||||
-- Concept allowed AND (junction had no proc-narrowing for this concept
|
||||
-- OR the matview row's proc matches one of the narrowing tuples for this concept).
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM unnest($pairs) AS p(concept_id uuid, proc_code text)
|
||||
WHERE p.concept_id = s.concept_id
|
||||
AND (p.proc_code IS NULL OR p.proc_code = s.proceeding_code)
|
||||
)
|
||||
OR s.kind = 'trigger'
|
||||
```
|
||||
|
||||
Or equivalently, pre-expand on the Go side: from the junction tuples, build two parallel arrays — `concept_ids_unconstrained text[]` (junction had `(c, NULL)`) and `pairs (concept_id, proc_code) (text, text)[]` (junction had a proc) — then:
|
||||
|
||||
```sql
|
||||
WHERE s.concept_id = ANY($unconstrained_concepts)
|
||||
OR (s.concept_id, s.proceeding_code) IN (SELECT * FROM unnest($pair_cids, $pair_procs))
|
||||
OR s.kind = 'trigger'
|
||||
```
|
||||
|
||||
Either form keeps the existing `WHERE concept_id = ANY` query plan happy and adds one bounded set-membership check per row. With the matview ~1k rows and per-leaf tuple sets ≤ ~30, both are sub-millisecond.
|
||||
|
||||
### 2.4 Where the fix touches code
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/services/event_category_service.go` | Add `ConceptOutcomesForSlug` (or rename `ConceptsForSlug` already returns `[]ConceptOutcome` — actually it does; expose it through a new search-friendly accessor that returns the two parallel arrays). Keep `ConceptIDsForSlug` for legacy callers but stop using it from the search service. |
|
||||
| `internal/services/deadline_search_service.go` | `Search` builds the tuple set from `eventCategory.ConceptOutcomesForSlug(slug)` instead of calling `ConceptIDsForSlug`. Pass tuples down to `browseRanks`, `loadPills`, `rankConcepts`. Update the SQL in all three to filter by tuple, not by concept_id alone. |
|
||||
| `internal/services/deadline_search_service.go` BrowseAll path | Stays as-is — when the user has picked NO leaf, all (concept × proc) combos are valid. Currently goes through `allMappedConceptIDs`; after the fix, change to "select distinct (concept_id, proceeding_type_code) from event_category_concepts" so we still respect any concept-context narrowing that's encoded in the junction even at the root view. |
|
||||
|
||||
The forum filter in `Forums` (`?forum=upc_cfi,upc_coa…`) keeps its current AND semantics — it ANDs against the tuple narrowing, never overrides it. After this fix, picking "UPC Verletzung opposing party" in the tree narrows to UPC_INF; adding a forum chip "EPA Einspruch" produces zero results (the user just contradicted themselves and the empty state is correct).
|
||||
|
||||
### 2.5 No migration needed for the bug fix
|
||||
|
||||
The seed data is fine — the per-leaf `proceeding_type_code` was always there. The Go-side wiring just dropped it. Fix is pure Go + SQL, no migration. Phase A in §4.
|
||||
|
||||
### 2.6 Test plan for the fix
|
||||
|
||||
Browser smoke tests (Phase A's PR should ship with these as Playwright cases or a manual checklist):
|
||||
|
||||
| Path | Expected pills (post-fix) |
|
||||
|---|---|
|
||||
| `b1=cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr` | `defence-to-counterclaim-for-revocation` (UPC_INF), `application-to-amend` (UPC_INF), `reply-to-defence` (UPC_INF) — no DE/EPA/DPMA pills |
|
||||
| `b1=cms-eingang.gegenseite.de-inf.klageerwiderung` | `reply-to-defence` (DE_INF only) — no UPC/EPA |
|
||||
| `b1=cms-eingang.gericht.hinweisbeschluss` | `response-to-preliminary-opinion` (DE_NULL only after §3 audit fix; currently shows DE_NULL because matview only has DE_NULL for that concept) |
|
||||
| `b1=frist-verpasst.epa` | `wiedereinsetzung` (cross-cutting/NULL), `weiterbehandlung` (cross-cutting/NULL) — both no proceeding chip |
|
||||
| `b1=` (root, browse-all) | every concept × proc tuple in the junction, ordered by sort_order |
|
||||
|
||||
DB-level invariant to assert in a unit test of `EventCategoryService`:
|
||||
|
||||
```go
|
||||
for _, leaf := range leaves {
|
||||
outcomes := svc.ConceptOutcomesForSlug(ctx, leaf.Slug)
|
||||
pills := svc.searchPillsForOutcomes(ctx, outcomes)
|
||||
for _, p := range pills {
|
||||
if p.Kind != "rule" { continue }
|
||||
// Check: pill's (concept_id, proc_code) was authorised by an outcome.
|
||||
ok := false
|
||||
for _, o := range outcomes {
|
||||
if o.ConceptID != p.ConceptID { continue }
|
||||
if o.ProceedingTypeCode == nil { ok = true; break }
|
||||
if *o.ProceedingTypeCode == p.ProceedingCode { ok = true; break }
|
||||
}
|
||||
require.True(t, ok, "leaf %s leaked pill (%s, %s)", leaf.Slug, p.ConceptSlug, p.ProceedingCode)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If this had existed in t-paliad-133 the bug would never have shipped. Adding it is part of Phase A.
|
||||
|
||||
---
|
||||
|
||||
## 3 — RoP-rigorous tree audit
|
||||
|
||||
### 3.1 Audit method
|
||||
|
||||
For each leaf in the seed (49 leaves with non-NULL `proceeding_type_code`, plus the cross-cutting NULL-coded ones), we cross-checked:
|
||||
|
||||
1. Does the proceeding the leaf maps to actually have a rule for that concept? (i.e. matview returns a row.)
|
||||
2. Does the cited RoP / PatG / EPÜ rule match what a HLC patent lawyer would expect to file in that situation?
|
||||
3. Are there other concepts that legitimately fire from the same leaf but were missed in the seed?
|
||||
|
||||
The tree shape — six root buckets (CMS-Eingang / Mündliche Verhandlung / Beschluss-Entscheidung / Frist verpasst / Ich möchte einreichen / Sonstiges) — is **kept**. m locked it on 2026-05-05 and the structure is sound; the failures are at the leaf-junction level, not in the categorisation.
|
||||
|
||||
### 3.2 Confirmed errors in the seed
|
||||
|
||||
| Leaf | Junction row (current) | Problem | Fix |
|
||||
|---|---|---|---|
|
||||
| `cms-eingang.gericht.hinweisbeschluss` | `response-to-preliminary-opinion \| DE_INF` | DE_INF (LG infringement) has no Hinweisbeschluss step. The Hinweisbeschluss is a BPatG-only mechanism (PatG §83). The matview confirms: this concept exists only for DE_NULL. | DELETE the DE_INF row. Keep the DE_NULL row. |
|
||||
| `cms-eingang.gegenseite.upc-inf.klageschrift` | `notice-of-defence-intention \| UPC_INF` | UPC has no "notice of intention to defend" rule in the corpus. The closest UPC artefact (R.23 explicit reaction) is captured by `statement-of-defence` directly. The matview has this concept only in DE_INF. | DELETE the UPC_INF row. Add `statement-of-defence \| UPC_INF` (already present at sort 200 — keep). |
|
||||
| `cms-eingang.gericht.kostenfestsetzung` | `notice-of-appeal \| UPC_COST_APPEAL` | Wrong rule. The actual rule for cost-decision appeal is `cost.leave_app` → `application-for-leave-to-appeal` (R.221.1), not `notice-of-appeal`. Matview confirms: `application-for-leave-to-appeal` exists only in UPC_COST_APPEAL; `notice-of-appeal` exists in UPC_APP but not UPC_COST_APPEAL. | UPDATE concept slug to `application-for-leave-to-appeal`. |
|
||||
| `beschluss-entscheidung.kostenfestsetzung` | `notice-of-appeal \| UPC_COST_APPEAL` | Same problem as the row above. | Same fix. |
|
||||
| `ich-moechte-einreichen.berufung.upc-cost` | `notice-of-appeal \| UPC_COST_APPEAL` | Same problem. | Same fix. |
|
||||
| `ich-moechte-einreichen.berufung.upc-coa-orders` | `application-for-leave-to-appeal \| UPC_APP_ORDERS` | The UPC_APP_ORDERS proceeding has `app_ord.discretion` (R.220.3 discretionary review) and `app_ord.with_leave` (R.220.2 appeal with leave) — NOT `application-for-leave-to-appeal` (which is the cost-appeal mechanism). | UPDATE the second row to `request-for-discretionary-review \| UPC_APP_ORDERS`. Keep `appeal-with-leave \| UPC_APP_ORDERS` row. |
|
||||
| `cms-eingang.gericht.anordnung` | `request-for-discretionary-review \| NULL` | Looks correct on its own (R.220.3 review is the response to a court order), but the NULL means it'd surface in every proceeding. The right narrowing is UPC_APP_ORDERS only. | UPDATE proc to `UPC_APP_ORDERS`. |
|
||||
|
||||
### 3.3 Coverage-gate exempt list — drop one entry
|
||||
|
||||
The migration 049 coverage gate exempts 4 concepts from leaf-reachability:
|
||||
|
||||
```
|
||||
'filing', 'request-for-examination', 'approval-and-translation', 'reply-to-cross-appeal'
|
||||
```
|
||||
|
||||
`reply-to-cross-appeal` was added to the exempt list in commit `ff36528` because it's downstream of cross-appeal. But after this audit, **it should be reachable** from at least:
|
||||
|
||||
- `cms-eingang.gegenseite.upc-inf.berufungsschrift` (when the Anschlussberufung filed by the opposing side triggers the user's response — the user IS the appellant who needs to respond to the cross-appeal)
|
||||
- `cms-eingang.gegenseite.upc-rev.berufungsschrift` (same logic for revocation appeals)
|
||||
- `cms-eingang.gegenseite.de-inf.berufungsschrift-olg` (DE OLG flavour — `cross-appeal \| DE_INF_OLG` already mapped, the reply has no DE rule in the corpus today, so this is UPC-only)
|
||||
|
||||
**Add** `reply-to-cross-appeal \| UPC_APP` rows under the two UPC `…berufungsschrift` leaves AND `reply-to-cross-appeal \| UPC_APP_ORDERS` under appropriate UPC_APP_ORDERS appeal leaves. **Drop** from the exempt list.
|
||||
|
||||
The other 3 exempt slugs are correctly cross-cutting (filing / examination / translation are EP_GRANT prosecution steps that don't fit the "what just happened" mental model — leave them exempt).
|
||||
|
||||
### 3.4 Bilateral-side coverage gaps
|
||||
|
||||
The seed correctly captures the receiving side ("CMS-Eingang" → opposing party / court actions) but the proactive side `ich-moechte-einreichen.spaetere-schriftsaetze` is missing some common UPC paths:
|
||||
|
||||
| Missing leaf | Concepts to map |
|
||||
|---|---|
|
||||
| `ich-moechte-einreichen.spaetere-schriftsaetze.anschlussberufung-upc` | `cross-appeal \| UPC_APP` (and `\| UPC_APP_ORDERS` if `app_ord.cross` is the right rule per R.237/238 — verify against the corpus rules `app.cross_a` / `app_ord.cross`) |
|
||||
| `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben` | `r116-final-submissions \| EPA_OPP` and `\| EPA_APP` (matview confirms these exist; user should be able to reach this proactively before an EPA hearing, not only through the "Mündliche Verhandlung → Geladen" leaf) |
|
||||
| `ich-moechte-einreichen.spaetere-schriftsaetze.kostenantrag-upc` (already exists) | Already mapped to `application-for-cost-decision \| UPC_INF`. Verify whether UPC_REV also has it (matview shows only UPC_INF — likely just UPC_INF is correct since R.151 is referenced from infringement context, but flag for m's confirmation). |
|
||||
|
||||
### 3.5 The `bescheid-mit-frist` orphan
|
||||
|
||||
`cms-eingang.gericht.bescheid-mit-frist` ("Order with court-set deadline") has **no junction rows**. Currently a dead-end leaf. The right mapping is the cross-cutting `schriftsatznachreichung` trigger event (the generic "submit something within the court-set period" event).
|
||||
|
||||
Add `(cms-eingang.gericht.bescheid-mit-frist, schriftsatznachreichung, NULL, 100)`.
|
||||
|
||||
### 3.6 What we are NOT changing
|
||||
|
||||
- **Tree shape** — the six root buckets stay (m's lock).
|
||||
- **Tree depth** — stays unlimited.
|
||||
- **Forum bucket map** in `services/deadline_search_service.go:64` — stays the 10-bucket layout (m's lock §10 Q8).
|
||||
- **`is_bilateral` flag and the perspective selector** — out of scope here. v3 deferred Phase D-2 (party-perspective UI) explicitly; v4 keeps that deferral.
|
||||
- **Trigger event taxonomy** (`paliad.trigger_events`) — out of scope.
|
||||
- **`primary_party` semantics** — `'both'` rules continue to use the perspective-selector resolution (v3 §5.1).
|
||||
|
||||
### 3.7 Required leaf-by-leaf review during implementation
|
||||
|
||||
The audit above is high-confidence on the bugs explicitly listed. But a thorough leaf-by-leaf RoP review would benefit from a fresh pass with the corrected pill set visible — that's a one-hour task for the coder shift, not an inventor task. The deliverable for Phase C is a SQL diff against the current `event_category_concepts` rows, with one comment per row citing the RoP/PatG/EPÜ rule. m can spot-check 5–10 leaves and approve/reject the diff in one review pass.
|
||||
|
||||
---
|
||||
|
||||
## 4 — Migration plan — three independent phases
|
||||
|
||||
The phases are deliberately decoupled so the bug fix (the user-visible regression) can ship first without waiting on taxonomy revision. Each phase is one or more atomic commits with an integration test.
|
||||
|
||||
### Phase A — Filter narrowing fix (no schema change)
|
||||
|
||||
- New `EventCategoryService.ConceptOutcomesForSlug` that returns the tuple set with proceeding context preserved.
|
||||
- `DeadlineSearchService.Search` (and helpers `browseRanks`, `loadPills`, `rankConcepts`) accept and apply the tuple constraint.
|
||||
- Update `allMappedConceptIDs` → `allMappedOutcomes` to return tuples for browse-all mode (so the root view also respects per-leaf narrowing).
|
||||
- Add `internal/services/deadline_search_service_test.go` covering the leaks listed in §2.6.
|
||||
- One commit, one PR.
|
||||
|
||||
No migration. No client-side changes. Pure backend correctness fix. Ships independently of B and C.
|
||||
|
||||
### Phase B — Card-click flow
|
||||
|
||||
- New `FristenrechnerService.CalculateRule(ctx, params)` in `internal/services/fristenrechner.go`.
|
||||
- New handler `handleFristenrechnerCalculateRule` in `internal/handlers/fristenrechner.go`.
|
||||
- New route `POST /api/tools/fristenrechner/calculate-rule` in `handlers.go`.
|
||||
- Frontend additions in `frontend/src/client/fristenrechner.ts`:
|
||||
- `expandCard(card, pill)` builds the inline calc panel.
|
||||
- `runCardCalc(rule, triggerDate, flags)` POSTs to the new endpoint and renders the result.
|
||||
- Card-row click handler (already wired for pill drill-in via `wirePillClicks`) extended to also handle "click on card body, not on a pill".
|
||||
- Reuse `openSaveModal` (`client/fristenrechner.ts:332`) with a single-deadline payload variant.
|
||||
- i18n keys: `deadlines.card_calc.trigger_date`, `…flag.<flag_name>`, `…result.due`, `…result.original`, `…add_to_project`.
|
||||
- CSS: `.fristen-card.is-expanded` + the panel zones in `global.css`.
|
||||
|
||||
No migration, no schema change. Depends on Phase A landing first (otherwise the cards are still showing wrong pills and the calc panel computes correctly but on the wrong rules).
|
||||
|
||||
### Phase C — RoP-rigorous tree taxonomy revision
|
||||
|
||||
- One new migration `052_event_categories_rop_audit.up.sql` (and `.down.sql`).
|
||||
- Pure data migration. No DDL. Updates `event_category_concepts` rows per §3.2–§3.5.
|
||||
- **No `RAISE EXCEPTION` coverage gates** — last night's outage was caused by exactly that pattern. Use `RAISE WARNING` at most. Coverage gates that block server boot are an ops failure mode the migration runner should not have. Validation gates can be a separate read-only check (a Go invariant test that runs in CI but doesn't block migrations).
|
||||
- The migration applies idempotently — every row uses `INSERT … ON CONFLICT DO UPDATE` or `DELETE WHERE …` (idempotent on re-run).
|
||||
- Drop `'reply-to-cross-appeal'` from the exempt list (it's now reachable). Keep the other 3 exempt slugs.
|
||||
|
||||
Ships independently of A and B. Ordering recommendation: A → C → B (because B's UX is best evaluated against a correct tree, but B is not technically blocked on C).
|
||||
|
||||
### What we are deliberately not doing in this round
|
||||
|
||||
- **No party-perspective UI** (v3 Phase D-2 defer holds).
|
||||
- **No AI Frist-Extraktion** (Phase H is deferred per m's 2026-04-16 decision).
|
||||
- **No CalDAV write-back of card-click deadlines** — happens through the existing `POST /api/projects/{id}/deadlines/bulk` which already triggers CalDAV sync via the deadline service.
|
||||
- **No multi-rule cards calc** — if a card has 2+ pills, the calc panel handles ONE pill at a time (the user picks which). Adding "calculate both at once" is feature creep.
|
||||
- **No persistent calc state** — collapsing the card discards the trigger date and flags. Users who want to keep working state should "Add to project" first.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Open questions for m before coder shift
|
||||
|
||||
The hardest decisions here are taxonomy and UX, both of which warrant a confirm-before-build:
|
||||
|
||||
1. **Card-click compute scope.** When the card has 1 pill, the calc panel works on that pill. When the card has 2+ pills (after §2 fix this should be rare — mostly `wiedereinsetzung`/`weiterbehandlung` cards and a few cross-jurisdictional concepts like `notice-of-appeal`), should the user pick ONE pill and compute, or should the calc panel produce a side-by-side comparison ("DE: 1 month → 5 June 2026; UPC: 2 months → 5 July 2026")? The latter is more powerful but doubles the UI surface. **Recommendation: stick with single-pill picker.** Comparison view is a future feature.
|
||||
|
||||
2. **Add-to-project source string.** Use `"fristenrechner"` (existing) or `"fristenrechner_card"` (new tag for card-click adds)? **Recommendation: new tag** so the audit log distinguishes the two flows. One-line addition to whatever validates the source field.
|
||||
|
||||
3. **Default trigger date.** Today (`new Date()`) or the user's most-recent trigger date from any prior calc this session? **Recommendation: today.** Prior-date carry-over is surprising; the user's last action in any calc tool is rarely the same as the next.
|
||||
|
||||
4. **`bescheid-mit-frist` mapping.** §3.5 proposes mapping to `schriftsatznachreichung` (cross-cutting / NULL proceeding). Is there a more specific concept I'm missing for "court-set period to file something" in the German PatG/ZPO corpus? If so, point me at the rule and I'll map it instead.
|
||||
|
||||
5. **`cost-appeal` rule labels.** §3.2 fixes 3 leaves to use `application-for-leave-to-appeal` instead of `notice-of-appeal` for UPC_COST_APPEAL. **Confirmation needed:** under R.221, is `application-for-leave-to-appeal` strictly the *first* step (15 days), with the actual `notice-of-appeal` as a *second* step once leave is granted? If so, should the leaf surface BOTH (sort 100 leave + sort 200 notice, conditional)?
|
||||
|
||||
6. **Phase ordering.** A → C → B (correctness first, then taxonomy, then UX) vs. A → B → C (correctness first, then UX so users see card-click immediately, then taxonomy as a follow-up). **Recommendation: A → C → B.** B is most valuable when the cards show the right pills, and C ships without UI risk.
|
||||
|
||||
7. **Coverage-gate replacement.** Phase C drops the `RAISE EXCEPTION` block. Should we replace it with a Go-side `services_test.go` unit test that asserts every `category='submission'` concept (less the 3-slug exempt list) is reachable from at least one leaf? **Recommendation: yes.** It's the same gate, just at CI time instead of migration time, and it can be made part of the `make test` target so it gates merges without gating server boots.
|
||||
|
||||
8. **Project picker autosuggest.** The existing `frist-save-modal` shows a `<select>` of all the user's visible projects. With 100+ Akten this becomes unwieldy. Worth adding a typeahead? **Defer** — out of scope here, but flag for a future task.
|
||||
|
||||
---
|
||||
|
||||
## 6 — Sequencing summary
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Phase A: Filter fix (Go + SQL only, no migration) │
|
||||
│ → ships independently, fixes m's repro │
|
||||
│ │
|
||||
│ Phase C: Tree taxonomy revision (migration 052) │
|
||||
│ → ships independently, fixes m's "RoP-rigorous" concern │
|
||||
│ → no RAISE EXCEPTION │
|
||||
│ │
|
||||
│ Phase B: Card-click → calculate → add-to-project │
|
||||
│ → new endpoint + frontend panel + reuse save modal │
|
||||
│ → most valuable after A + C land │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
m's open questions in §5 should be resolved before Phase B begins (UX choices) and Phase C migration is written (taxonomy choices). Phase A can start immediately on m's go-ahead; it has no open questions.
|
||||
|
||||
---
|
||||
|
||||
## 7 — Changelog vs v3
|
||||
|
||||
What this design changes about v3:
|
||||
|
||||
- **Card-click is no longer a dead-end.** v3 ended at the result card; v4 makes the card the *entry* to a single-rule calculator + add-to-project flow.
|
||||
- **Per-leaf proceeding narrowing actually narrows.** v3 had the data right but dropped it in `ConceptIDsForSlug`; v4 carries the tuple end-to-end.
|
||||
- **One concrete RoP-mapping bug class fixed**: 6 leaves had wrong concept↔proc rows; the bug was masked because the broken filter showed all proceedings anyway. Once §2's fix lands, these leaves would have produced empty cards instead of overbroad cards — surfacing the seed errors. Phase C corrects them.
|
||||
- **No new schema columns.** Same tables (`event_categories`, `event_category_concepts`, `deadline_rules.is_bilateral`); just data corrections + Go logic.
|
||||
Reference in New Issue
Block a user