Compare commits

...

1 Commits

Author SHA1 Message Date
mAi
2e0fadeaa2 design(fristenrechner): follow-up rules — cross-party + scenario SSoT + spawn-only picker filter (t-paliad-327)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Three independent fixes, one design doc:
- Bug A: /follow-ups filters cross-party rules out (claimant perspective drops
  defendant follow-ups). Fix: backend returns all rows + new is_cross_party
  computed field; UI badges as "Gegenseitig", default unchecked, muted style,
  excluded from write-back.
- Bug B: Verfahrensablauf scenario flag checkboxes and result-view conditional
  group are independent. SSoT: new paliad.projects.scenario_flags jsonb column
  + GET/PATCH /api/projects/{id}/scenario-flags. Both surfaces read on mount
  and sync via DOM CustomEvent on document. Kontextfrei stays on localStorage.
- Bug C (added by head 10:45): four *.appeal_spawn events appear in Mode A
  search but are spawn consequences, not real triggers. Fix: add
  sr.is_spawn = false to SearchEvents WHERE block.

m's 8 design decisions (AskUserQuestion, 2 batches of 4) all landed on
recommendation:
- "Gegenseitig" badge / always-visible muted styling / cross-party wins
  + write-back excluded
- projects.scenario_flags jsonb / localStorage for kontextfrei
- Composite condition_expr checkbox disabled in result view
- DOM CustomEvent for sync, single-tab v1
- Terminal-leaf events (Duplik etc.) stay in picker — empty result is honest UX

Two worked examples:
- def_to_ccr claimant trigger → RoP.029.d (defendant) shown with Gegenseitig
  badge + with_ccr conditional badge + write-back excluded
- Mode A search for "Berufung" → only upc.apl.unified.* triggers surface;
  *.appeal_spawn rows hidden
- Verfahrensablauf ↔ result-view round-trip via PATCH + CustomEvent

No code yet — coder gate held per inventor SKILL. Design only.

Closes the inventor pass on m/paliad#148.
2026-05-27 11:54:47 +02:00

View File

@@ -0,0 +1,589 @@
# Design — Fristenrechner follow-up rules: cross-party display + scenario-flag SSoT
**Task:** t-paliad-327
**Gitea:** m/paliad#148
**Inventor:** atlas (shift-1)
**Date:** 2026-05-27
**Status:** Draft — coder gate held until m ratifies the open questions in §8
**Branch:** `mai/atlas/inventor-followup-rules`
---
## 0. Premises verified live (2026-05-27 10:42)
Verified against the live `paliad` schema (youpc Postgres, port 11833) and the current `main` (post mig 153 / S6, HEAD = `2335472`). Two of the issue body's premises are factually off; this section anchors the design to what's actually in the database.
### 0.1 Bug A — the cross-party filter overreach (issue body correct)
`internal/services/fristenrechner_followups.go:358-367` filters follow-ups server-side:
```go
if party == "claimant" || party == "defendant" {
args = append(args, party)
where = append(where, fmt.Sprintf(
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
len(args)))
}
```
A claimant request drops every `primary_party='defendant'` follow-up. The locked-trigger flow then shows "Keine Folge-Fristen" when the only remaining work in the cascade is the opposing side's filing. m's concrete repro:
- Trigger: `upc.inf.cfi.def_to_ccr` (RoP.029.a — Erwiderung auf Nichtigkeitswiderklage, claimant-filed, mandatory, `condition_expr = {"flag": "with_ccr"}`)
- Child: `upc.inf.cfi.reply_def_ccr` (RoP.029.d — Replik auf Erwiderung zur CCR, defendant-filed, mandatory, `condition_expr = {"flag": "with_ccr"}`)
- Claimant perspective → child dropped → empty result.
The data is correct; the filter is wrong.
### 0.2 Bug B — what the SSoT actually looks like today
The issue body asserts three stores: `project_event_choices`, `projects.scenarios` jsonb, and DOM-only. **The live state is different and simpler:**
| Surface | Where state lives today | Schema reality |
|---|---|---|
| Verfahrensablauf scenario checkboxes (`ccr-flag`, `inf-amend-flag`, `rev-amend-flag`, `rev-cci-flag`) | **localStorage**, keyed by `SCENARIO_KEYS.ccr` etc. (`frontend/src/client/verfahrensablauf.ts:330-343`) | Per-browser, per-user; never reaches the DB. |
| Result-view CONDITIONAL group checkboxes | **DOM-only**, in a per-card selection Set in `fristenrechner-result.ts` | Never persists. |
| `paliad.scenarios` table (Litigation Planner Slice D, mig 145) | Empty (0 rows). | `(id, project_id, name, description, spec jsonb, ...)`. `spec.flags[]` carries scenario flag list. CRUD endpoints exist (`/api/scenarios/*`) but no UI surface writes to them yet. |
| `paliad.project_event_choices` table | Empty (0 rows). | Columns: `(id, project_id, submission_code, choice_kind, choice_value, audit cols)`. **No `scenario_name` column** despite the issue body's claim. |
| `paliad.projects.scenarios` jsonb | **Does not exist.** | The only project-side column for this is `active_scenario_id uuid` (FK → `paliad.scenarios`). |
**Net:** today there's exactly **one** behavioural store (per-browser localStorage) and **two empty DB tables waiting for a use case**. No data to migrate. The design picks one DB store going forward; everything else stays dormant.
### 0.3 The flag vocabulary in `condition_expr` — three names
Live `sequencing_rules.condition_expr` distinct shapes (4 patterns, 3 atomic flags):
```json
{"flag": "with_ccr"}
{"flag": "with_amend"}
{"flag": "with_cci"}
{"op": "and", "args": [{"flag": "with_ccr"}, {"flag": "with_amend"}]}
```
`with_ccr` = Widerklage auf Nichtigkeit; `with_amend` = R.30 Antrag auf Patentänderung; `with_cci` = Counterclaim for Infringement (UPC rev). The full vocabulary is 3 names, stable since mig 084 (the `condition_expr` backfill). Verfahrensablauf's checkbox set maps 1:1 to this vocabulary.
### 0.4 `parent_id` IS the predecessor link — confirmed
Live stats over `paliad.sequencing_rules` (active+published, 226 rows):
- 107 rules (47%) have `parent_id` set → they sit below a predecessor in the cascade tree.
- 119 rules (53%) have `parent_id = NULL` → top-of-tree anchors OR genuine leaves not yet linked.
- 67 unique rule ids appear as `parent_id` for ≥1 child → **67 rules act as triggers in today's data**.
m's architectural call (10:35): no explicit `event_role` column on `procedural_events`. Trigger-ness is **derived** from "does any other published rule's `parent_id` point at me?". This matches what `LookupFollowUps` already does (one hop down via `parent_id`) — the design just makes that semantic explicit and writes it down (the original overhaul doc said "anchored on `sr.procedural_event_id = trigger.id`", which is the anchor lookup, not the follow-up resolution).
### 0.5 Anchor files
- `internal/services/fristenrechner_followups.go` — the server-side resolver (Bug A lives here)
- `internal/handlers/fristenrechner_followups.go` — the HTTP wire layer (`GET /api/tools/fristenrechner/follow-ups`)
- `frontend/src/client/fristenrechner-result.ts` — result view; renders the 4 priority groups, owns the conditional checkbox state in DOM
- `frontend/src/client/verfahrensablauf.ts` — owns the scenario-flag checkboxes + localStorage
- `frontend/src/client/views/verfahrensablauf-core.ts` — shared rendering between procedure-mode + verfahrensablauf
- `internal/handlers/scenarios.go` + `internal/services/scenario_service.go` — Litigation Planner Slice D CRUD (already shipped, unused by UI)
---
## 1. The three design pillars
This design covers three structurally independent fixes that share one underlying primitive (the `parent_id` semantic + the spawn-as-consequence semantic). Each pillar lands its own slice; the design doc treats them as one because they ship together.
| Pillar | Bug | Surface | DB delta |
|---|---|---|---|
| **§2 Cross-party display** | A | Backend filter (server) + result view (client) | None |
| **§3 Scenario-flag SSoT** | B | Result view + Verfahrensablauf binding | One new minimal column on `projects` |
| **§3a Spawn-only events excluded from picker** | C | Backend search (server) | None |
The `parent_id` semantic (§4) is documentation-only and updates the prior overhaul doc; no code change.
### 0.6 Bug C — spawn-only events appear in the picker
Live data (verified 2026-05-27 10:47): exactly **4 events** in the corpus have their **only** anchored rule with `is_spawn = true`:
| event_code | event_name | rule | spawn_label | spawn target |
|---|---|---|---|---|
| `upc.inf.cfi.appeal_spawn` | Berufung gegen Endentscheidung | RoP.220.1.a (spawn-only) | Berufungsverfahren öffnen | `upc.apl.merits` |
| `upc.rev.cfi.appeal_spawn` | Berufung gegen Endentscheidung (Nichtigkeit) | RoP.220.1.a (spawn-only) | Berufungsverfahren öffnen | `upc.apl.merits` |
| `upc.pi.cfi.appeal_spawn` | Berufung gegen Anordnung | RoP.220.1.a (spawn-only) | Berufungsverfahren öffnen | `upc.apl.merits` |
| `upc.dmgs.cfi.appeal_spawn` | Berufung gegen Schadensentscheidung | RoP.220.1.a (spawn-only) | Berufungsverfahren öffnen | `upc.apl.merits` |
These events are not standalone triggers — they're spawn-CONSEQUENCES of an `Endentscheidung` rule on the parent proceeding. m's call: an appeal spawn isn't a "what happened" event the user picks; it's a downstream consequence the result view surfaces under the SPAWNED group (Fristenrechner overhaul §4.2).
The filter is the same architectural pattern as everything else in this design: **derive from data, don't add a column.** A picker-eligible event is one with ≥1 active+published non-spawn rule:
```sql
-- Picker filter on procedural_events:
WHERE pe.is_active = true
AND pe.lifecycle_state = 'published'
AND EXISTS (
SELECT 1 FROM paliad.sequencing_rules sr_anchor
WHERE sr_anchor.procedural_event_id = pe.id
AND sr_anchor.is_active = true
AND sr_anchor.lifecycle_state = 'published'
AND sr_anchor.is_spawn = false
)
```
Equivalent inline in the existing `SearchEvents` query (`internal/services/fristenrechner_search_events.go:101`) — the JOIN already constrains to one anchor rule per event; add `sr.is_spawn = false` to the WHERE block. Events whose only rule is a spawn rule get no JOIN match → drop out of the result set. See §3a for the worked spec.
---
## 2. Cross-party follow-ups (Bug A)
### 2.1 The semantic distinction
A follow-up rule's `primary_party` is **about who files the deadline**, not about who's interested in it. A claimant lawyer planning a UPC INF case sees the defendant's RoP.029.d Replik because:
- They need to know it's coming.
- The Replik's date computes from THEIR own RoP.029.a (the Erwiderung auf CCR, claimant-filed).
- They don't put the Replik into their own Aktenkalender as a filing they make — but they want it on the timeline as an opposing-side milestone.
So "perspective" is a **display qualifier**, not a query filter. Server returns all follow-ups; UI renders own-side vs other-side with different affordances.
### 2.2 Backend change — stop filtering by party
`internal/services/fristenrechner_followups.go:345` (`queryFollowUpRows`): remove the `party` arg's WHERE clause. Return every rule under `parent_id = anchor`.
Concretely:
```go
// BEFORE
if party == "claimant" || party == "defendant" {
args = append(args, party)
where = append(where, fmt.Sprintf(
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
len(args)))
} else if party != "" {
args = append(args, party)
where = append(where, fmt.Sprintf("sr.primary_party = $%d", len(args)))
}
// AFTER
// No party filter — return all follow-ups under this anchor.
// The result view groups by own-side / cross-party / court using
// `primary_party` (already in the response payload) compared against
// the request's `party` (echoed back in resp.Party).
```
The `party` query param stays — but it becomes a **display hint** the handler echoes back to the client. The wire shape grows one optional field per row (or the client derives it):
```jsonc
// FollowUpRule (unchanged) already carries primary_party. Adding one
// computed/derived field for clarity:
{
"rule_id": "...",
"primary_party": "defendant", // existing
"is_cross_party": true, // NEW — true when primary_party != response.party
...
}
```
`is_cross_party` is set server-side by comparing each row's `primary_party` to the request's `party`. For `party='claimant'` and `primary_party='defendant'`: `is_cross_party = true`. For `primary_party='both'`, `IS NULL`, or `'court'`: `is_cross_party = false` (these aren't "the other side"). The frontend doesn't have to re-derive.
This is one new boolean field, zero new SQL columns, zero database migrations.
### 2.3 Frontend rendering contract
In `frontend/src/client/fristenrechner-result.ts`, the priority grouping `groupByPriority()` (line ~289) continues to bucket by `mandatory / recommended / optional / conditional`. Within each group, rows render with a new **cross-party affordance** when `is_cross_party = true`:
- **Badge:** small chip on the row: `Gegenseitig` (DE) / `Other side` (EN). Subdued visual treatment — `--colour-text-muted` text, no own-side gold/lime accent. (Final wording in §8 Q1.)
- **Default checked state:** unchecked. The lawyer is not filing this deadline; they're tracking it.
- **Date visible.** The computed date renders normally (calculator runs server-side regardless of party). It's the same calendar event whether claimant or defendant is the agent.
- **Subtle row styling.** Muted background, no checkbox-implicit invitation to write back. (Final visual in §8 Q2 — collapsed-under-fold vs always-visible-greyed.)
- **Write-back exclusion.** When the user clicks "In Akte eintragen ▶", cross-party rows are excluded from the bulk-insert payload **even if the user has manually checked them**. Wrong shape: a claimant lawyer shouldn't be able to file a defendant deadline into their own Aktenkalender. (Final write-back policy in §8 Q3.)
The conditional group still picks up cross-party rows — `has_condition` and `is_cross_party` are independent. RoP.029.d satisfies both (`condition_expr = {"flag": "with_ccr"}` AND defendant-side under claimant perspective). Rendering is: conditional group → row → has both badges. Display precedence in §8 Q3 (if the row ends up in conditional group, is it the conditional badge that gates checkbox state or the cross-party badge? Recommend: cross-party wins because it's a stronger filter — even if `with_ccr=true`, a claimant doesn't file the defendant's Replik).
### 2.4 Why not just keep the SQL filter and only relax for cross-party?
Two reasons:
1. **Single responsibility.** The SQL gives raw data; the UI decides what to highlight. Mixing UI-driven exclusion into a SQL filter creates a leaky contract — the test in `fristenrechner_followups_test.go:181` already asserts the current filter behaviour and would need a parallel "but show defendant rules under claimant" caveat. Cleaner to push policy to one layer.
2. **The handler stays a thin pass-through.** `/api/tools/fristenrechner/follow-ups` is a knowledge-tool endpoint; treating party as a presentation hint matches its role. The eventual project-write-back endpoint (`POST /api/projects/{id}/deadlines/bulk`, t-paliad-322 §4.4) is the only place where "this lawyer's own filings" matters — and that's a different API surface.
### 2.5 Test coverage
Update `internal/services/fristenrechner_followups_test.go`:
- Existing test at L181 (`upc.inf.cfi.soc` + defendant): re-asserts the **new** behaviour — returns all rows, defendant-side rows have `is_cross_party=false`, claimant-side rows have `is_cross_party=true`.
- Add: `upc.inf.cfi.def_to_ccr` + claimant perspective → returns the RoP.029.d child with `is_cross_party=true`, `primary_party='defendant'`, `has_condition=true`.
- Add: `is_cross_party=false` regardless of party for rules with `primary_party IN ('both', 'court')` and for `primary_party IS NULL`.
---
## 3. Scenario-flag SSoT (Bug B)
### 3.1 What we're binding
Three concepts that today live in three different places:
| Concept | Today | Should be |
|---|---|---|
| "This project has a CCR / R.30 amend / CCI" | `verfahrensablauf.ts` localStorage, per-browser | One project-level fact, persisted, all surfaces read it |
| Result-view CONDITIONAL group "check this conditional" | DOM-only Set | Same project-level fact |
| Verfahrensablauf flag checkboxes | localStorage | Same project-level fact (in Akte mode) |
In **kontextfrei mode** (no project), localStorage stays the store. The binding becomes: "Verfahrensablauf checkboxes ↔ result-view conditional checkboxes" via localStorage and a tiny DOM event. No DB write.
In **Akte mode** (project set), the store is the DB.
### 3.2 SSoT location — recommended: `projects.scenario_flags jsonb`
The minimal viable home: a single new column on `paliad.projects`.
```sql
-- Migration NNN (next free after mig 153, so likely 154 or whatever
-- coder finds at write-time).
ALTER TABLE paliad.projects
ADD COLUMN scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN paliad.projects.scenario_flags IS
'Project-level scenario flag state — keys map 1:1 to '
'sequencing_rules.condition_expr flag names (with_ccr, with_amend, '
'with_cci, ...). Values are bool. Read by Verfahrensablauf + '
'Fristenrechner result-view to gate conditional rules.';
-- Optional index if we expect WHERE scenario_flags ? 'with_ccr' filters
-- (e.g. for "show me all projects with a CCR"). Skip for v1.
```
Shape:
```json
{
"with_ccr": true,
"with_amend": false,
"with_cci": true
}
```
Empty `{}` = no flags set = behave as today (conditional rules hidden by default).
#### Why this beats the four candidates
| Candidate | Verdict |
|---|---|
| (a) Auto-create a `paliad.scenarios` row per project, store flags in `scenarios.spec.flags` | Forces every flag toggle to round-trip through the scenarios CRUD layer; clutters the scenarios list ("active flags" pseudo-rows mixed with user-named scenarios); requires a new "system" scenario type. Over-engineered for 3 booleans. |
| **(b) New `projects.scenario_flags jsonb` column (Recommended)** | One column, atomic UPDATE, no joins, no API surface change beyond the project endpoint. |
| (c) New `paliad.project_scenario_flags(project_id, flag, value)` table | Three writes per common multi-flag UPDATE; admin overhead for a query that's never run with WHERE flag = X. |
| (d) Repurpose `paliad.project_event_choices` | Schema doesn't fit (the `submission_code` PK part is mandatory; flags aren't per-submission). Would need ALTER + composite keys + semantic muddling. |
Recommendation locks (b). m's call in §8 Q4.
#### Relationship with `paliad.scenarios`
`scenarios.spec.flags[]` (the Litigation Planner array shape) is **complementary**, not a duplicate:
- `projects.scenario_flags` = the live state of the project's flag toggles. Always writable, always reflects "what does the lawyer see right now."
- `scenarios.spec.flags` = a named snapshot ("Vorlage: mit CCR"). When the lawyer activates a scenario (`projects.active_scenario_id`), the system can copy `scenario.spec.flags` array into `projects.scenario_flags` map. That copy is a one-shot — once copied, the lawyer edits the live `scenario_flags`; if they want to revert, they re-activate the scenario.
This keeps the live state cheap to read (one column) and the snapshot/template feature intact for when m's "complex scenarios" v2 lands.
### 3.3 API contract
Two new endpoints — read + atomic write. They live on the project resource because that's where the data is.
```
GET /api/projects/{id}/scenario-flags
→ 200 { "scenario_flags": { "with_ccr": true, ... } }
→ 404 if project not visible to caller (RLS via can_see_project)
PATCH /api/projects/{id}/scenario-flags
Body: { "with_ccr": true } // partial — only listed keys are updated
Body: { "with_ccr": null } // null deletes the key
→ 200 { "scenario_flags": { ... merged ... } }
→ 403 if caller can't edit project (RLS)
→ 422 if unknown flag key (validate against the known set)
```
Validation: the handler accepts only keys in a known whitelist (the union of all `condition_expr` flag names — easy to derive at boot time via a one-time SELECT). Unknown keys → 422 with the list of valid flag names. This stops typos becoming silent no-ops.
### 3.4 Frontend binding
Three places consume / produce flag state:
**(1) `verfahrensablauf.ts`** — the Verfahrensablauf knowledge tool. Behaviour today:
- Renders checkboxes by DOM id.
- Reads/writes localStorage.
After binding:
- In Akte mode: on mount, fetch `GET /api/projects/{id}/scenario-flags` → seed the checkboxes. On checkbox change, `PATCH` the project; on success, mirror to localStorage (keep the localStorage entry as a per-user fallback for next-time Akte switch).
- In Kontextfrei mode: read/write localStorage as today. No DB call.
- Dispatch a `scenario-flag-changed` CustomEvent on `document` after every successful change (with `detail: { flag: 'with_ccr', value: true }`).
**(2) `fristenrechner-result.ts`** — the result view's CONDITIONAL group. Behaviour today: DOM-only Set, no persistence.
After binding:
- On mount (with a project context, the `?project=...` query param): seed the conditional checkboxes from `GET /api/projects/{id}/scenario-flags`. The mapping is by the rule's `condition_expr` — a rule whose expression is `{"flag": "with_ccr"}` and `scenario_flags.with_ccr === true` → render checked by default.
- On checkbox change: derive the flag from the rule's `condition_expr`. If atomic (`{"flag": "X"}`) — `PATCH` the project with `{ X: <new value> }`.
- If composite (`{"op": "and", "args": [{"flag":"with_ccr"}, {"flag":"with_amend"}]}`) — the checkbox represents the COMPOSITE state. Tricky case — see §8 Q6. **Strawman:** disable the checkbox in result view for composite-conditional rows; rely on the Verfahrensablauf to toggle individual flags, and the composite rule renders as "depends on both flags being set". m's call.
- Listen for `scenario-flag-changed` events: when fired, re-evaluate which conditional rows should be checked and update DOM (no server round-trip — the event already carries the new state).
**(3) `verfahrensablauf-core.ts`** — shared between procedure-mode and Verfahrensablauf. Reads `flags[]` array from the request param when calling `calc`. Behaviour today: builds the array from localStorage.
After binding:
- In Akte mode: builds the array from the current `scenario_flags` map (which was already seeded from the DB on mount; localStorage is a cache).
- In Kontextfrei: same as today (localStorage).
- This is read-only; writes still flow through (1) and (2).
### 3.5 Sync mechanism — DOM CustomEvent (not polling, not SSE)
Both surfaces are in the same SPA page (or browser tab) at runtime. A `CustomEvent('scenario-flag-changed', { detail: {flag, value} })` dispatched on `document` is the cheapest valid sync — no polling overhead, no WebSocket complexity. Cross-tab sync (lawyer opens two tabs) is **out of scope** for v1 (§8 Q7); if it matters, add a `storage` event listener to mirror `localStorage` changes in kontextfrei mode (Akte mode would need an SSE/poll — not for v1).
### 3.6 Migration plan
**Data:** zero migration needed. Both candidate tables (`project_event_choices`, `scenarios`) are empty. localStorage state stays in localStorage — when an Akte-mode user first opens the page after the mig lands, the seed-from-DB is an empty `{}`. Their localStorage flags **don't** auto-promote to the project (they may belong to a different project / be stale from kontextfrei use). The user re-ticks the checkboxes; the next PATCH lands them in DB. Honest behaviour for v1.
**Schema:** one ADD COLUMN with DEFAULT, fully reversible.
**Rollout:** mig 154 (or whatever number) lands; backend endpoints land in the same PR; frontend binding lands in a follow-up PR. The frontend stays correct against either schema state — if the column doesn't exist, the GET 404s and the surfaces silently fall back to localStorage. (Coder sanity check: make sure the API treats a missing column / 404 as "no flags set" rather than crashing.)
---
## 3a. Spawn-only events excluded from the picker (Bug C)
### 3a.1 The semantic
A rule with `is_spawn=true` doesn't represent something a user does AT a moment in time — it represents a transition from one proceeding into another (here: CFI → CoA appeal). The event row that carries a spawn-only rule (the four `*.appeal_spawn` events) exists in the database so the spawn rule has a `procedural_event_id` to attach to; the event isn't a real procedural milestone.
Mode A search (typed text + filters) and Mode B R4 (wizard chip-strip) both query `procedural_events` for triggers the user can lock. The contract: **a trigger is a moment that has at least one non-spawn follow-up obligation rooted on it**. A spawn-only event fails that test.
### 3a.2 Backend change — one WHERE clause
`internal/services/fristenrechner_search_events.go` (Mode A):
```go
// Add to the where[] slice (around L107):
where = append(where, "sr.is_spawn = false")
```
The existing query already JOINs `sequencing_rules sr ON pe.id = sr.procedural_event_id`. With the new clause, an event whose only attached rule is a spawn rule gets no JOIN match → excluded from the result set. An event with both spawn and non-spawn rules keeps the non-spawn rule as the anchor.
Identical patch wherever the Mode B R4 chip-strip is computed. (If the wizard re-uses `SearchEvents` — confirm in code — one patch suffices. If R4 has its own query, the same `sr.is_spawn = false` clause applies.)
### 3a.3 What still shows up in the result view
Spawn rules are NOT hidden — they continue to render in the **SPAWNED group** under the parent event's result view (per `docs/design-fristenrechner-overhaul-2026-05-26.md` §4.2 Q5: spawned rules fold into their priority bucket with a `⇲ neues Verfahren` badge).
Concretely: when the user picks `upc.inf.cfi.endentscheidung` as trigger, the Endentscheidung's rule has the spawn rule as a child (via `parent_id`). The result view's follow-up query already collects every child via `parent_id = anchor.id`. The spawn child appears in the result with its spawn badge. The user can't pick `upc.inf.cfi.appeal_spawn` as a trigger (it's not in the search), but they can still see "Berufung gegen Endentscheidung" as a SPAWN consequence when the Endentscheidung is the trigger.
### 3a.4 Edge case — terminal leaves (Duplik, etc.)
m's adjacent question (per head's instruction): some events are terminal — the last move in a chain (e.g. `upc.inf.cfi.duplik` per RoP.029.c). They have a non-spawn anchor rule but no own children (no rules with `parent_id` pointing at them). The picker filter (§3a.2) still admits them — they have ≥1 non-spawn rule, so they pass.
Result view for a terminal-leaf trigger: empty follow-ups list (no children). The UI renders that honestly — "Keine Folge-Fristen für dieses Ereignis" — which is correct UX for a terminal move. The user already knows "Duplik" is the end of the cascade; the empty result confirms it.
**Recommendation:** terminal leaves stay in the picker. §8 Q8 lets m revisit if he wants them filtered out (would require `EXISTS (rule whose parent_id points at me)` — derive triggerhood from "is anyone's parent" instead of "has a non-spawn rule").
### 3a.5 Test coverage
Update `internal/services/fristenrechner_search_events_test.go`:
- Existing tests asserting search returns all matches for "Berufung" / "Appeal" / similar: re-assert that the 4 `*.appeal_spawn` events are **excluded** post-patch.
- Add: query `q="Berufung"` returns only `upc.apl.unified.*` events (the real appeal-proceeding triggers); none of the `appeal_spawn` events.
- Add: query the upc.inf.cfi.endentscheidung event → confirm the spawn child still appears in its result view's follow-ups (§3 of the overhaul) — this asserts the spawn rule isn't being filtered out everywhere, just in the picker.
---
## 4. Documenting the `parent_id` semantic
The original Fristenrechner overhaul design (`docs/design-fristenrechner-overhaul-2026-05-26.md` §4.2) names "anchor" without naming the follow-up resolution. Patch the doc with one sentence:
> **Follow-up resolution:** `follow-ups = sequencing_rules WHERE parent_id = anchor_rule.id`. The `anchor_rule.id` is the `sequencing_rule.id` whose `procedural_event_id` matches the locked trigger event. Each follow-up's own children (the next hop) are loaded lazily if the user drills into it; this design renders only **one hop** down. Trigger-ness is derived from the data — a rule's "trigger" character means "≥1 published+active sequencing_rule has my id as parent_id". No `event_role` column on `procedural_events`.
That's it for §4 — pure documentation. The patch lands in the same commit as the §2 backend change so the doc and code stay aligned.
The **editorial backlog** — the 119/226 rules with `parent_id IS NULL` that should chain back to anchors — is flagged out of scope (§5). The result view continues to render "this trigger has no follow-ups" honestly when the trigger event's anchor rule has no children; that's a correct UX state for leaves.
---
## 5. Out of scope
- **Editorial backfill of `parent_id IS NULL` rules.** 119 rows. Separate ticket: review each by hand or build an editor heuristic ("which event predecedes this one in `event_categories`?"). Not blocked by this design and not blocking it.
- **`event_role` column on `procedural_events`.** Explicitly rejected by m. No change to the events schema.
- **Cross-tab sync of scenario flags.** v2 if it matters. Single tab works; refreshing the other tab re-fetches.
- **Multi-user concurrent edits to scenario_flags.** Last-write-wins via PATCH; no optimistic-lock token. Realistic: the lawyer is the only one toggling these.
- **Replacing the legacy `?legacy=1` Procedure-mode page.** That surface uses the same `verfahrensablauf-core.ts` → it picks up the new binding for free in Akte mode. Kontextfrei stays on localStorage as today.
- **`paliad.project_event_choices` removal.** Empty + unused → safe to leave dormant. A separate audit may DROP it once it's clear nothing in the app reads/writes it.
- **`paliad.scenarios` snapshot ↔ live `scenario_flags` round-trip.** When a user "activates" a scenario, the spec.flags[] should copy into `projects.scenario_flags`. That's the Litigation Planner Slice D follow-on; not this design.
- **Calculator (pkg/litigationplanner.CalculateRule) changes.** None.
---
## 6. Worked examples
### 6.1 Cross-party — claimant picks `def_to_ccr` as trigger
State:
- Project HL-2024-001, `proceeding_type=upc.inf.cfi`, `our_side=claimant`, `scenario_flags={"with_ccr": true}`.
- User opens `/tools/fristenrechner`, picks "🧭 Geführt" wizard mode.
- R1 = Eingereicht (filing); R2 = UPC (prefilled); R3 = upc.inf.cfi (prefilled); R4 = "Erwiderung auf Nichtigkeitswiderklage" (RoP.029.a).
Locks trigger. Result-view request:
```
GET /api/tools/fristenrechner/follow-ups
?event=upc.inf.cfi.def_to_ccr
&trigger_date=2026-06-15
&party=claimant
&project=<HL-2024-001 uuid>
```
After §2.2 change, server returns:
```json
{
"trigger": { "code": "upc.inf.cfi.def_to_ccr", "name_de": "Erwiderung auf ...", ... },
"trigger_date": "2026-06-15",
"party": "claimant",
"follow_ups": [
{
"rule_id": "d11f1e57-...",
"event_code": "upc.inf.cfi.reply_def_ccr",
"title_de": "Replik auf Erwiderung zur Nichtigkeitswiderklage",
"primary_party": "defendant",
"is_cross_party": true,
"priority": "mandatory",
"has_condition": true,
"due_date": "2026-08-15",
"rule_code": "RoP.029.d",
...
}
]
}
```
Result view renders (mocked):
```text
MANDATORY
▢ Replik auf Erwiderung zur Nichtigkeitswiderklage [Gegenseitig] [bedingt: with_ccr]
RoP.029.d · Beklagtenseite 15.08.2026
ⓘ ...
```
- Unchecked by default (cross-party).
- Date computed and visible.
- "Gegenseitig" badge + "bedingt: with_ccr" badge.
- Muted styling.
- "In Akte eintragen" CTA selection counter does NOT include this row even if the user checks it (per §2.3 write-back exclusion).
If the user **un**-ticks `with_ccr` in the binding-aware Verfahrensablauf chip, the row collapses into the conditional-hidden state (visible only if "Auch nicht-zutreffende Bedingungen anzeigen" is on, per current Mode B affordance). The cross-party badge persists when the row IS visible.
### 6.2 Spawn-only event excluded from Mode A search ("Berufung" only matches real triggers)
State:
- Project HL-2024-001, `proceeding_type=upc.inf.cfi`.
- User opens `/tools/fristenrechner?project=HL-2024-001`, picks "⚡ Direkt suchen", types `Berufung`.
Request:
```
GET /api/tools/fristenrechner/search?kind=events&q=Berufung&forum=upc
```
**Pre-patch results (today, the bug):**
```
- "Berufung gegen Endentscheidung" upc.inf.cfi.appeal_spawn 1 Folge-Frist ← BUG
- "Berufung gegen Endentscheidung (Nichtigkeit)" upc.rev.cfi.appeal_spawn 1 Folge-Frist ← BUG
- "Berufung gegen Schadensentscheidung" upc.dmgs.cfi.appeal_spawn 1 Folge-Frist ← BUG
- "Berufung gegen Anordnung" upc.pi.cfi.appeal_spawn 1 Folge-Frist ← BUG
- "Berufungsbegründung (Hauptberufung)" upc.apl.unified.notice N Folge-Fristen
- "Berufungserwiderung" upc.apl.unified.reply N Folge-Fristen
- ...
```
The user picks "Berufung gegen Endentscheidung" thinking it's a real trigger. The locked-trigger result view loads — and shows zero meaningful follow-ups (the spawn rule has no children of its own; it's a consequence, not a trigger). Dead-end UX.
**Post-patch results (this design):**
```
- "Berufungsbegründung (Hauptberufung)" upc.apl.unified.notice N Folge-Fristen
- "Berufungserwiderung" upc.apl.unified.reply N Folge-Fristen
- ...
```
The 4 `*.appeal_spawn` events are gone from the picker. The user sees only real triggers. The Berufung-Spawn rules still appear: under the parent Endentscheidung's result view, in the SPAWNED bucket with the `⇲ Berufungsverfahren öffnen` badge.
### 6.3 Verfahrensablauf ↔ result-view round-trip
State:
- Same project HL-2024-001, `scenario_flags={}` initially.
User journey:
1. User opens `/tools/verfahrensablauf?project=HL-2024-001`. Page mount calls `GET /api/projects/HL-2024-001/scenario-flags``{}`. All flag checkboxes unticked.
2. User clicks "Mit Widerklage auf Nichtigkeit" (the `ccr-flag` checkbox).
3. Handler in `verfahrensablauf.ts`:
- `PATCH /api/projects/HL-2024-001/scenario-flags { "with_ccr": true }` → success.
- Mirrors to localStorage for next-time fallback.
- Dispatches `document.dispatchEvent(new CustomEvent('scenario-flag-changed', { detail: { flag: 'with_ccr', value: true } }))`.
4. User switches mode tab → "⚡ Direkt suchen", searches "Klageerhebung", clicks the upc.inf.cfi.soc result.
5. Result view mounts. Sees `?project=HL-2024-001`. Calls `GET /api/projects/HL-2024-001/scenario-flags``{"with_ccr": true}`.
6. Renders the CONDITIONAL group with the CCR-conditional rules **pre-checked**.
7. User toggles a different CCR-conditional row off. Handler:
- Looks up the rule's `condition_expr` from the response payload.
- Atomic `{"flag": "with_ccr"}``PATCH /api/projects/HL-2024-001/scenario-flags { "with_ccr": false }`.
- Dispatches the event.
8. The Verfahrensablauf surface (still mounted in another iframe / split view / next page nav) listens; on next mount the data is fresh.
The user has a single mental model: "this project has a CCR" — toggling that fact in either surface updates the other.
---
## 7. Migration plan + rollout
Three slices, parallel-landable:
| Slice | What ships | Schema | Branch |
|---|---|---|---|
| **S1 — Cross-party** | Backend: drop the `party` WHERE clause + add `is_cross_party` to response. Frontend: render cross-party badge + unchecked default + muted style + write-back exclusion. Doc patch on the overhaul design §4.2. | None | One PR off main |
| **S1a — Spawn-only picker filter** | Backend: `sr.is_spawn = false` added to `SearchEvents` WHERE block (and wizard R4 query if separate). Tests assert appeal_spawn rows drop from search. | None | Can ship with S1 in one PR (both are search-layer fixes with no schema delta) |
| **S2 — Scenario SSoT** | Mig: ADD COLUMN `projects.scenario_flags jsonb`. Backend: GET + PATCH endpoints on `/api/projects/{id}/scenario-flags` + whitelist validation. Frontend: Verfahrensablauf reads/writes via API in Akte mode; result-view conditional group binds; CustomEvent sync. | mig N (next free) | One PR off main |
S1+S1a ship first because Bug A and Bug C are user-visible lies ("Keine Folge-Fristen" when there are follow-ups; "Berufung gegen Endentscheidung" looking like a trigger). S2 follows.
All slices are reversible at code level. S2's mig is `DROP COLUMN`-reversible (no data loss because no rows exist with non-empty `scenario_flags` pre-deploy).
---
## 8. Open questions for m (8 questions)
Sent via `AskUserQuestion` in 2 batches per inventor SKILL contract (4+4).
| # | Topic | Recommended pick |
|---|---|---|
| Q1 | Cross-party badge wording | "Gegenseitig" (DE) / "Other side" (EN) |
| Q2 | Cross-party visual treatment | Always visible, muted+greyed (not collapsed under a fold) |
| Q3 | Cross-party + conditional combo + write-back exclusion | Cross-party wins for the checkbox semantic; exclude from write-back unconditionally |
| Q4 | SSoT column location | `paliad.projects.scenario_flags jsonb` |
| Q5 | Kontextfrei (no-project) binding | Stay on localStorage; no DB writes when project_id is null |
| Q6 | Composite `condition_expr` (and-of-flags) checkbox in result view | Disabled in result-view; Verfahrensablauf is the toggle surface |
| Q7 | Sync mechanism | DOM CustomEvent on `document`, single-tab only for v1 |
| Q8 | Terminal leaves (Duplik etc.) in picker | Keep them — empty result view is honest UX |
---
## 9. m's decisions (2026-05-27)
All 8 questions answered via `AskUserQuestion` on 2026-05-27 ~10:50 (2 batches of 4+4). **All 8 picks = recommendation.** No deviations to capture; the strawman holds as-is.
- **Q1 (Cross-party badge): "Gegenseitig" / "Other side".** [= recommendation] **Locks §2.3 badge wording.**
- **Q2 (Cross-party visual): Always visible, muted + greyed.** [= recommendation] **Locks §2.3 visual treatment.** Cross-party rows render in their priority group with the badge and a muted/greyed style, unchecked by default, date visible. No collapse, no toggle.
- **Q3 (Cross-party + conditional + write-back): Cross-party wins — unchecked, write-back excluded.** [= recommendation] **Locks §2.3 write-back exclusion.** A claimant lawyer cannot file the defendant's RoP.029.d into their own Aktenkalender even by manually checking the box; the bulk write-back filters cross-party rows unconditionally.
- **Q4 (SSoT shape): `paliad.projects.scenario_flags jsonb`.** [= recommendation] **Locks §3.2 schema + §3.3 API contract.** One new jsonb column, default `'{}'`, atomic PATCH via `/api/projects/{id}/scenario-flags`.
- **Q5 (Kontextfrei store): Stay on localStorage.** [= recommendation] **Locks §3.4 + §3.5 kontextfrei flow.** No DB calls when project_id is null. Per-browser state. Cross-tab sync via the existing `storage` event already works.
- **Q6 (Composite checkbox): Disabled in result-view; Verfahrensablauf is the toggle surface.** [= recommendation] **Locks §3.4 result-view rule.** Composite-conditional rows render the badges + the dependent flags but the checkbox is read-only.
- **Q7 (Sync mechanism): DOM CustomEvent on `document`, single-tab only for v1.** [= recommendation] **Locks §3.5.** Cross-tab in Akte mode is out of scope for v1.
- **Q8 (Terminal leaves): Keep them — empty result view is honest UX.** [= recommendation] **Locks §3a.4.** The picker filter admits any event with a non-spawn rule; Duplik (and other terminal moves) stay pickable; result view renders "Keine Folge-Fristen" honestly when there are no children.
### 9.1 What changed from the strawman as a result
Nothing. All 8 picks landed on-recommendation. The §0-§8 body holds as written; no follow-on edits needed beyond §10 update below.
---
## 10. Synthesis links
- mBrian topic: `topic-fristenrechner` — file as `[synthesis]` linked `triggered_by` t-paliad-327; `related_to` the Fristenrechner overhaul + proceeding_types taxonomy + Litigation Planner scenarios.
- Cross-refs: `docs/design-fristenrechner-overhaul-2026-05-26.md` (parent design, S1-S6 shipped 2026-05-27), `docs/design-proceeding-types-taxonomy-2026-05-26.md` (mig 153 shipped 2026-05-27), `docs/design-litigation-planner-2026-05-26.md` §5 (Litigation Planner Slice D = `paliad.scenarios` snapshots).
- Related migrations: 084 (`condition_expr` backfill), 145 (`paliad.scenarios`), 153 (`proceeding_types.kind`).