Files
paliad/docs/plans/unified-fristenrechner-v4.md
m 30ac337a78 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.
2026-05-05 12:11:36 +02:00

34 KiB
Raw Permalink Blame History

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:5811: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 515 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 397404. 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:

  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:

-- 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:

  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_appapplication-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 510 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 allMappedConceptIDsallMappedOutcomes 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.