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.
34 KiB
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:
- 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.
- Filter narrowing is broken. Picking "CMS-Eingang → Gegenseite → UPC Verletzung" still surfaces national submissions. Confirmed bug — see §2.
- 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) ANDweiterbehandlung(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-opinioninDE_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:
{
"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:
{
"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:
{ "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:
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:
- Resolves slug → concept_ids:
[id-of-statement-of-defence, id-of-reply-to-defence, …]. DropsUPC_INF. - Loads from matview every row where
concept_idmatches → all 9 proceedings ofstatement-of-defence, since the matview row exists for every (concept × rule) combo across the corpus (matview 047). - 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 likewiedereinsetzung,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:
-- 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:
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:
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:
- Does the proceeding the leaf maps to actually have a rule for that concept? (i.e. matview returns a row.)
- Does the cited RoP / PatG / EPÜ rule match what a HLC patent lawyer would expect to file in that situation?
- 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_OLGalready 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_bilateralflag 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_partysemantics —'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.ConceptOutcomesForSlugthat returns the tuple set with proceeding context preserved. DeadlineSearchService.Search(and helpersbrowseRanks,loadPills,rankConcepts) accept and apply the tuple constraint.- Update
allMappedConceptIDs→allMappedOutcomesto return tuples for browse-all mode (so the root view also respects per-leaf narrowing). - Add
internal/services/deadline_search_service_test.gocovering 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)ininternal/services/fristenrechner.go. - New handler
handleFristenrechnerCalculateRuleininternal/handlers/fristenrechner.go. - New route
POST /api/tools/fristenrechner/calculate-ruleinhandlers.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 inglobal.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_conceptsrows per §3.2–§3.5. - No
RAISE EXCEPTIONcoverage gates — last night's outage was caused by exactly that pattern. UseRAISE WARNINGat 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 UPDATEorDELETE 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/bulkwhich 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:
-
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/weiterbehandlungcards and a few cross-jurisdictional concepts likenotice-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. -
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. -
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. -
bescheid-mit-fristmapping. §3.5 proposes mapping toschriftsatznachreichung(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. -
cost-appealrule labels. §3.2 fixes 3 leaves to useapplication-for-leave-to-appealinstead ofnotice-of-appealfor UPC_COST_APPEAL. Confirmation needed: under R.221, isapplication-for-leave-to-appealstrictly the first step (15 days), with the actualnotice-of-appealas a second step once leave is granted? If so, should the leaf surface BOTH (sort 100 leave + sort 200 notice, conditional)? -
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.
-
Coverage-gate replacement. Phase C drops the
RAISE EXCEPTIONblock. Should we replace it with a Go-sideservices_test.gounit test that asserts everycategory='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 themake testtarget so it gates merges without gating server boots. -
Project picker autosuggest. The existing
frist-save-modalshows 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.