# 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.
