docs(litigation-planner): Slice B design — Berufung unification + multi-axis catalog query + primary_party CHECK (m/paliad#124)
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

Adds §18 to the design doc folding in m's three 2026-05-26 decisions:

§18.1 Berufung unification — collapse 3 active UPC appeal proceeding_types
(upc.apl.merits / upc.apl.cost / upc.apl.order, 16 rules total) into ONE
upc.apl + appeal_target discriminator. 5 targets: Endentscheidung,
Kostenentscheidung, Anordnung, Schadensbemessung, Bucheinsicht. Adds
proceeding_types.appeal_target + deadline_rules.applies_to_target[]
columns; archives the 3 old codes; CalcOptions gains AppealTarget filter.
Migration 134 with pre-migration audit pass. Q to m on whether
Schadensbemessung-as-appeal shares the merits rule set (R) or has its own.

§18.2 Multi-axis catalog query API — new Catalog.LookupEvents method
taking optional {jurisdiction, proceeding_type_id, party,
event_category_id, appeal_target} axes + EventLookupDepth control
("next" / "all-following"). No schema delta — reuses existing parent_id
+ sequence_order graph. Returns EventMatch with priority + depth metadata.

§18.3 primary_party enum tightening — CHECK constraint on
deadline_rules.primary_party against canonical four-value vocab
(claimant/defendant/court/both, plus NULL for orphan concept seeds).
Live audit confirmed all 26+26+38+63 proceeding-bound rows already
conform; the 78 NULL rows are all proceeding_type_id IS NULL orphans
(cross-cutting concepts) and stay NULL. Migration 135 with audit-first
RAISE NOTICE pass. Package exposes PrimaryParties[] + IsValidPrimaryParty().

§18.4 revises §10 slice plan: B1 (Berufung), B2 (catalog query), B3
(enum tightening). Independent + parallel-friendly.

Branch: mai/cronus/inventor-litigation-slice-b (off main d1d0cf9).
NOT reusing the merged Slice A branch.
This commit is contained in:
mAi
2026-05-26 13:37:26 +02:00
parent d1d0cf9c1d
commit acf5743fa3

View File

@@ -1141,4 +1141,302 @@ Slice F is a youpc-side task; it needs a worker with youpc-go familiarity (a sep
--- ---
## §18 Slice B — Catalog Interface + Unifications (2026-05-26)
Slice A landed atomically at `d1d0cf9`. Before Slice B's coder shift begins, three additional decisions m confirmed today need to be folded into the package design:
- §18.1 **Berufung unification**. Collapse the 3 active UPC appeal proceeding_types (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) into ONE `upc.apl` proceeding type + an `appeal_target` discriminator.
- §18.2 **Multi-axis catalog query API**. New `Catalog.LookupEvents` method taking any subset of `{jurisdiction, proceeding_type_id, party, event_category_id}` axes + a depth control (`next` / `all-following`).
- §18.3 **`primary_party` enum tightening**. Convert the free-text `paliad.deadline_rules.primary_party` column to a CHECK constraint matching the four-value vocabulary `claimant / defendant / court / both`.
Each subsection follows the same shape: motivation schema impact API shape acceptance criteria.
### §18.0 Live state on main (audit summary)
Confirmed via Supabase before drafting (`mai/cronus/inventor-litigation-slice-b` branch off `main`):
- **9 active UPC proceeding_types**: `upc.inf.cfi` (25 rules), `upc.rev.cfi` (17), `upc.pi.cfi` (7), `upc.dmgs.cfi` (8), `upc.disc.cfi` (4), `upc.ccr.cfi` (0 sub-track), `upc.apl.merits` (7), `upc.apl.cost` (2), `upc.apl.order` (7).
- **3 appeal-flavoured proceeding_types** = 16 rules across 3 codes. Schadensbemessung + Bucheinsicht are SEPARATE first-instance proceedings today (`upc.dmgs.cfi`, `upc.disc.cfi`), NOT appeal sub-tracks.
- **`paliad.deadline_rules.primary_party`** value distribution: `claimant=26`, `defendant=26`, `court=38`, `both=63`, `NULL=78`. The 78 NULL rows are ALL `proceeding_type_id IS NULL` orphans (cross-cutting concept seeds: Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung 8 distinct concepts × N rules). Every proceeding-bound rule already has a four-value `primary_party`.
- **`paliad.event_categories.party`** column shape: `text[]` (array). Live distinct values: `{claimant}`, `{defendant}`, NULL. No `court` or `both` in event_categories.party today. The semantic is "from whose perspective is this event triggered?" narrower than `primary_party` which is "who files this submission".
### §18.1 Berufung unification
#### Motivation
m's framing (2026-05-26 09:55, t-paliad-298 instructions): *"the Verfahrensablauf event picker has 4-5 separate proceeding_types … plus Berufung Schadensbemessung and Berufung Bucheinsicht variants. m doesn't like the pre-separation. He wants ONE 'Berufung' entry in the picker, and the user then picks what the appeal is directed AT … the system derives the correct frist sequence from that target."*
Today's 3 codes (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) are a leaky abstraction of "appeal" the user has to know whether it's a merits/cost/order appeal BEFORE they enter the picker, even though that branching question is "what's being appealed?" not "what kind of appeal?". Schadensbemessung + Bucheinsicht aren't in the appeal taxonomy at all today; appeals against those decisions silently fall into `upc.apl.merits`, blurring the rule sequence (RoP.220 vs RoP.221 vs RoP.224 timing).
The five appeal-target kinds are:
| Target | Source decision | Typical RoP track | Current proceeding code |
|---|---|---|---|
| Endentscheidung | Final merits decision (UPC.RoP.118.1 / 219) | 2-month notice + 4-month grounds (R.224.1.a / R.224.2.a) | `upc.apl.merits` |
| Kostenentscheidung | Cost decision (R.150 / R.221.1) | 15-day leave-to-appeal (R.221.1) | `upc.apl.cost` |
| Anordnung | Order during proceedings (R.220) | 15-day track (R.220.2 / R.220.3 / R.224.2.b) | `upc.apl.order` |
| Schadensbemessung | Damages-determination decision (R.118.4 + R.140.2.b damages award) | Same merits track (2/4 month), but conceptually distinct anchor | (today maps to `upc.apl.merits`, silently) |
| Bucheinsicht | Lay-open-books decision (R.142) | 15-day track (R.220.2 order-flavoured) OR merits track depending on the underlying decision shape | (today maps to `upc.apl.merits`, silently) |
#### Schema impact
**Migration plan (single `134_berufung_unification.up.sql`)**:
1. **Add column** `paliad.proceeding_types.appeal_target text NULL` discriminator on the unified `upc.apl` row.
2. **Add CHECK** on `appeal_target`: NULL OR one of `endentscheidung | kostenentscheidung | anordnung | schadensbemessung | bucheinsicht`. Slugged in English-lowercase to match the package's English-identifier rule; the user-facing label is i18n'd in frontend.
3. **Insert** a new unified row `upc.apl` (name="Berufungsverfahren", name_en="Appeal", jurisdiction="UPC", category="fristenrechner", `appeal_target=NULL`).
4. **Re-target rule rows** by `appeal_target`:
- Today's 7 `upc.apl.merits` rules keep `proceeding_type_id` pointing at the new `upc.apl` row, set a NEW column on `paliad.deadline_rules` called `applies_to_target text NULL` (CHECK matching the five-value vocab) to `'endentscheidung'`.
- Today's 2 `upc.apl.cost` rules `applies_to_target='kostenentscheidung'`.
- Today's 7 `upc.apl.order` rules `applies_to_target='anordnung'`.
- The 7 merits rules ALSO carry the implicit "applies to Schadensbemessung" semantic (the merits track is shared) explicit duplication or a multi-value applies_to_target array? See §18.1 "Open question" below.
5. **Archive** the 3 old proceeding_types set `category='archived'`, `is_active=false`. Keep the rows for FK integrity (project_event_choices, etc. may reference them historically; the archive flag stops them surfacing in the picker).
6. **Add 5 stable proceeding-type alias rows** OR **just emit one chip per appeal_target in the frontend**. Recommended (see API shape below): emit chips from the package's catalog, no DB row per target.
**Two new columns added by this migration:**
- `paliad.proceeding_types.appeal_target text NULL` (CHECK on 5 slugs OR NULL NULL means "not an appeal").
- `paliad.deadline_rules.applies_to_target text[] NULL` (CHECK each element the 5 slugs array because the merits track applies to BOTH endentscheidung AND schadensbemessung today).
**Migration audit pass first**: before running step 4 the migration should `RAISE NOTICE` for any rule row whose `applies_to_target` derivation is ambiguous (e.g. an old `upc.apl.merits` rule that has a `condition_flag` that doesn't fit any target). In practice the 16 rules all map cleanly, but the audit pattern matches Phase 2 Step E discipline (see `docs/design-fristen-phase2-2026-05-15.md` §3.E).
**Down-migration**: re-insert the 3 archived proceeding_types, restore `proceeding_type_id` on rules from the saved `applies_to_target`, drop the two new columns. Standard down-symmetry per `docs/design-fristen-phase2-2026-05-15.md`.
#### API shape
The package's existing `Catalog.LoadProceeding(ctx, code, hint)` already returns a `ProceedingType` + `[]Rule`. The Berufung unification fits cleanly:
- `LoadProceeding(ctx, "upc.apl", hint)` returns the unified Berufung proceeding + ALL appeal rules across the 5 targets.
- A new optional field on the request narrows by target: extend `CalcOptions` with `AppealTarget string`. When non-empty, the engine filters the returned rule list to rules whose `applies_to_target` contains the requested target.
- The package exposes the 5 target slugs as constants:
```go
const (
AppealTargetEndentscheidung = "endentscheidung"
AppealTargetKostenentscheidung = "kostenentscheidung"
AppealTargetAnordnung = "anordnung"
AppealTargetSchadensbemessung = "schadensbemessung"
AppealTargetBucheinsicht = "bucheinsicht"
)
// AppealTargets is the canonical ordered list for UI chip rendering.
var AppealTargets = []string{
AppealTargetEndentscheidung,
AppealTargetKostenentscheidung,
AppealTargetAnordnung,
AppealTargetSchadensbemessung,
AppealTargetBucheinsicht,
}
```
- `ProceedingType` gains a field: `AppealTarget *string ` db:"appeal_target" json:"appealTarget,omitempty"`` (per-row tag for clarity; redundant with the unified row's `code='upc.apl'` but useful for non-appeal proceedings that may carry NULL).
- `Rule` gains a field: `AppliesToTarget []string ` db:"applies_to_target" json:"appliesToTarget,omitempty"`` (per-row applies-to set).
Frontend logic:
- Verfahrensablauf picker shows one "Berufung" entry (the `upc.apl` proceeding).
- After picking Berufung, a chip group renders the 5 `AppealTargets` slugs (i18n labels in `frontend/src/client/i18n.ts`).
- Selecting a target sets `?target=<slug>` query param backend includes `opts.AppealTarget=<slug>` in the request engine filters.
#### Acceptance criteria (Slice B sub-tasks for this fold-in)
1. Migration `134_berufung_unification.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB.
2. After migration, `SELECT code FROM paliad.proceeding_types WHERE jurisdiction='UPC' AND is_active=true AND category='fristenrechner'` returns one less row (the 3 old appeal codes collapsed to 1 new code).
3. `Catalog.LoadProceeding(ctx, "upc.apl", hint)` returns the merged 16-rule set; with `opts.AppealTarget="endentscheidung"` it returns exactly 7 rules.
4. Verfahrensablauf renders one "Berufung" picker entry. The 5 target chips render below it post-pick; switching chips re-renders the timeline.
5. Existing project rows that referenced the old `upc.apl.merits` / `upc.apl.cost` / `upc.apl.order` codes still load (the FK integrity is preserved via the archived old rows).
6. The `paliad.proceeding_type_history` follow-up (not in scope here) can later migrate those project FKs to the new `upc.apl` + `appeal_target` field that's a follow-up.
#### Open question for m (escalate via `mai instruct head`)
**Q18.1.1 — Is Schadensbemessung-as-appeal a duplicate of Endentscheidung, or a distinct set?**
Three interpretations:
- A. **Shared rule set**: appeal of a Schadensbemessung uses the SAME rules as appeal of an Endentscheidung (both run the 2/4-month merits track). `applies_to_target=['endentscheidung','schadensbemessung']` on each rule. (R) simplest, matches what the live `upc.apl.merits` corpus does today (no explicit target distinction).
- B. **Distinct rule set**: Schadensbemessung-appeal has its own anchor + sequence (different trigger event, different timing). Would need 7 new rule rows specifically for `applies_to_target=['schadensbemessung']`. No live evidence for this today.
- C. **Defer**: ship Berufung unification with only 3 targets (endentscheidung / kostenentscheidung / anordnung) for v1; add Schadensbemessung + Bucheinsicht as a follow-up.
Recommendation: **A** share rules, multi-valued `applies_to_target` array. Frontend renders all 5 chips from day 1; the merits 7 rules show under endentscheidung + schadensbemessung; the order 7 rules show under anordnung + bucheinsicht (the 15-day track DOES apply to Bucheinsicht under R.142+R.220.2). No new rule rows needed.
**Q18.1.2 — User-facing label for "appeal_target": "Worauf richtet sich die Berufung?" (DE) / "Appeal against:" (EN)?**
Recommendation: yes, those exact strings; defer i18n decisions to the coder shift.
### §18.2 Multi-axis catalog query API
#### Motivation
The current `Catalog` interface (added in Slice A) supports proceeding-code lookups only. The new scenarios surface (Slice D) + the Determinator cascade (t-paliad-166) + a future "show me all next-step events when I'm in state X" need a generalised query that takes any subset of axes and returns matching events.
m's brief (2026-05-26 13:33): *"any subset of these axes (all optional): jurisdiction, proceeding_type_id, party, event_category_id. Returns matching events with the priority flag and a sequence-depth control: caller picks 'next' (1 hop downstream) or 'all-following' (full chain)."*
Today the cascade reconstructs this client-side via fanned-out calls to `/api/tools/fristenrechner` etc. fragile + duplicated logic. The new method centralises the graph walk in the package.
#### Schema impact
**None new.** The query reads existing tables:
- `paliad.proceeding_types` (jurisdiction, id, code)
- `paliad.deadline_rules` (parent_id, sequence_order, primary_party, priority, event_category_id via concept_id event_category_concepts)
- `paliad.event_categories` (id, party, parent_id for the cascade hierarchy)
- `paliad.event_category_concepts` (junction; concept_id event_category_id)
The depth control is a runtime graph walk `next` returns one hop from the matched parent, `all-following` walks `parent_id` recursively until leaves.
The audit found one schema gap worth flagging but NOT changing in Slice B:
- `paliad.deadline_rules` has no direct `event_category_id` column it goes through `concept_id → deadline_concepts → event_category_concepts → event_categories`. The join is well-trodden but introduces an extra hop. v1 of the catalog API uses the join; a future denormalisation (`paliad.deadline_rules.event_category_id` cached column) is out of scope.
#### API shape
```go
// EventLookupAxes carries the optional filter axes for LookupEvents. All
// fields are optional; the empty value is "no filter on this axis". When
// multiple axes are set the engine applies them as AND (a rule must
// match ALL non-zero axes).
type EventLookupAxes struct {
Jurisdiction string // "UPC" | "DE" | "EPA" | "DPMA" — empty = any
ProceedingTypeID *int // narrow to one proceeding — nil = any
Party string // "claimant" | "defendant" | "court" | "both" — empty = any
EventCategoryID *uuid.UUID // narrow to one event_categories row — nil = any
AppealTarget string // §18.1 fold-in — empty = any
}
// EventLookupDepth controls the sequence-depth of the returned events.
type EventLookupDepth string
const (
// EventLookupDepthNext returns immediate children of the matched
// anchor (1 hop downstream). Default for "what comes next from
// this point?" queries.
EventLookupDepthNext EventLookupDepth = "next"
// EventLookupDepthAllFollowing returns the entire downstream
// chain (parent_id walk to leaves). Default for "show me the
// whole sequence from here onward" queries.
EventLookupDepthAllFollowing EventLookupDepth = "all-following"
)
// EventMatch is one result row from LookupEvents.
type EventMatch struct {
Rule Rule `json:"rule"` // full rule row
ProceedingType ProceedingType `json:"proceedingType"` // owning proceeding
Priority string `json:"priority"` // mandatory|recommended|optional|informational
DepthFromAnchor int `json:"depthFromAnchor"` // 1 = next, 2+ = deeper
// ParentRuleID populated when the match has a parent_id in the
// returned set (so the frontend can render a tree).
ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
}
// LookupEvents on the Catalog interface returns events matching any
// subset of axes, at the requested sequence depth. Returns an empty
// slice (NOT an error) when no events match.
//
// Implementation must respect the catalog's "published + active" rule
// gate that LoadProceeding already enforces.
type Catalog interface {
// ... existing methods (LoadProceeding, LoadProceedingByID, ...)
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
}
```
paliad's `paliadCatalog` impl builds one SQL query with optional WHERE clauses + the existing `deadline_concept_event_types` JOIN for the event_category_id axis. youpc.org's embedded snapshot impl runs the same axis-filter pass on the in-memory rule slice.
#### Acceptance criteria (Slice B sub-tasks)
1. `Catalog.LookupEvents` exists on the interface + has both a paliad-side impl (SQL) and a stub for the future embedded/upc snapshot impl.
2. Round-trip test: `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all 77 UPC rules (matches the count from §0).
3. Combined-axis test: `EventLookupAxes{Jurisdiction:"UPC", Party:"claimant"}` returns the claimant-perspective subset.
4. Depth test: with a specific `ProceedingTypeID` + `Party:"defendant"`, `EventLookupDepthNext` returns only 1-hop children of the proceeding's root; `EventLookupDepthAllFollowing` returns the full chain.
5. New axis-driven endpoint at `GET /api/tools/lookup-events?…` proxies the call (separate slice out of scope for the package-side acceptance, but listed for the coder).
### §18.3 `primary_party` enum tightening
#### Motivation
Today `paliad.deadline_rules.primary_party` is free-text. The live values confirm a stable four-value vocabulary (`claimant=26, defendant=26, court=38, both=63, NULL=78`) but nothing prevents a future rule editor from typing `clamant` or `Court` and silently breaking the appellant-context propagation in `engine.go`.
m's brief: *"Tighten to a check constraint matching event_categories.party's allowed values: claimant/defendant/court/both. Migration must audit + clean existing rows first; surface dirty rows to m if any don't fit the four-value vocabulary."*
Note: `event_categories.party` is a `text[]` (array) with current live values `{claimant}`, `{defendant}`, NULL. It does NOT carry `court` or `both` today. The brief's "matching event_categories.party's allowed values" is taken to mean the SEMANTIC vocabulary (claimant/defendant/court/both), not the literal current rows of event_categories.party. The package owns the canonical list.
#### Schema impact
**Migration `135_primary_party_check.up.sql`**:
1. **Audit pass** (DO $$ block): COUNT rules where `proceeding_type_id IS NOT NULL AND primary_party NOT IN ('claimant', 'defendant', 'court', 'both', NULL)`. RAISE NOTICE for each non-matching row's `(id, name, primary_party)`. If COUNT > 0, RAISE EXCEPTION 'dirty rows — see notice; manual cleanup required'.
2. **Add CHECK constraint**: `ALTER TABLE paliad.deadline_rules ADD CONSTRAINT deadline_rules_primary_party_chk CHECK (primary_party IS NULL OR primary_party IN ('claimant', 'defendant', 'court', 'both'))`.
3. **No data change** — every proceeding-bound rule already has a valid four-value value; the 78 NULL rows are orphan concept seeds and stay NULL.
4. **Down-migration**: `ALTER TABLE paliad.deadline_rules DROP CONSTRAINT deadline_rules_primary_party_chk`. No data revert needed.
**Why NULL stays valid:**
- The 78 NULL rows are cross-cutting concept seeds (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) that have NO proceeding binding. They're not in the calculator's path; loosening the CHECK to `IS NULL OR IN (...)` keeps them valid without further schema gymnastics.
- A stricter "NOT NULL when proceeding_type_id is NOT NULL" partial constraint would be cleaner but adds a multi-column rule that's harder to maintain. The simpler form suffices given today's invariant.
**Should the same vocabulary be propagated to `paliad.event_categories.party`?**
Recommendation: **NO, not in this migration**. event_categories.party is array-shaped (a category can apply to multiple perspectives) and today carries only `{claimant}` / `{defendant}` per its narrower semantic ("from whose perspective is this category triggered?"). Tightening it to require court/both would force backfill of rows where neither perspective is the trigger. Out of scope for Slice B; flag as a follow-up.
#### API shape
The package's `Rule.PrimaryParty` field (already `*string`) stays as-is — the type doesn't change. A new package-level set of constants:
```go
const (
PrimaryPartyClaimant = "claimant"
PrimaryPartyDefendant = "defendant"
PrimaryPartyCourt = "court"
PrimaryPartyBoth = "both"
)
// PrimaryParties is the canonical ordered list for validation +
// admin-UI rendering.
var PrimaryParties = []string{
PrimaryPartyClaimant,
PrimaryPartyDefendant,
PrimaryPartyCourt,
PrimaryPartyBoth,
}
// IsValidPrimaryParty returns true for empty (NULL-equivalent) or any
// of the four canonical values. Used by the rule-editor (Slice E2 if
// ever revisited) to validate writes before they hit the CHECK.
func IsValidPrimaryParty(s string) bool { }
```
The rule-editor service (`internal/services/rule_editor_service.go`) gains a validation call against `lp.IsValidPrimaryParty` before any UPDATE — surfaces a user-friendly 400 before the DB CHECK fires with the less-pretty error.
#### Acceptance criteria (Slice B sub-tasks)
1. Migration `135_primary_party_check.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB.
2. Pre-migration audit pass surfaces zero dirty rows on the current live corpus (verified via Supabase audit before migration drafted).
3. Post-migration, attempting to UPDATE a rule's primary_party to `'foo'` raises a DB CHECK violation.
4. The package exposes the four constants + the `PrimaryParties` slice + the `IsValidPrimaryParty` predicate.
5. Rule-editor service surfaces 400 with a clear message when a write violates the constraint (instead of leaking the raw PG error).
### §18 Summary table
| § | Topic | Schema delta | API delta | Migration |
|---|---|---|---|---|
| 18.1 | Berufung unification | +2 columns (proceeding_types.appeal_target + deadline_rules.applies_to_target[]); collapse 3→1 active appeal codes | `Catalog` returns merged proceeding + Rule.AppliesToTarget; CalcOptions.AppealTarget filter; AppealTargets[] constants | `134_berufung_unification.up.sql` |
| 18.2 | Multi-axis catalog query | None new (uses existing joins) | `Catalog.LookupEvents(axes, depth)` new method + EventLookupAxes / EventMatch types | None |
| 18.3 | `primary_party` enum | +CHECK constraint on deadline_rules.primary_party | `PrimaryParties[]` constants + `IsValidPrimaryParty()` predicate | `135_primary_party_check.up.sql` |
### §18.4 Slice plan refinement (revises §10 for Slice B)
The original §10 listed Slice B as "Catalog / HolidayCalendar / CourtRegistry interfaces + paliad's default loaders." Slice A already folded those interfaces in (the engine.Calculate signature accepts them). Slice B's revised scope:
1. **Slice B1 — Berufung unification** (§18.1): migration 134 + package constants + `appeal_target` field on ProceedingType + `applies_to_target[]` on Rule + `CalcOptions.AppealTarget` filter. Frontend updates (verfahrensablauf chip group) follow in the same PR.
2. **Slice B2 — Multi-axis catalog query API** (§18.2): `Catalog.LookupEvents` method + paliad impl + tests. New `GET /api/tools/lookup-events` endpoint optional (slice C may want it earlier).
3. **Slice B3 — primary_party enum tightening** (§18.3): migration 135 + package constants + rule-editor validation hook.
B1 / B2 / B3 are independently shippable and can land in any order. B1 has the most user-facing impact (the picker change is what m flagged); B3 is the smallest hardening; B2 is the largest API surface.
### §18.5 Open questions escalated to head
- §18.1 Q1 — Schadensbemessung-as-appeal: shared vs distinct vs deferred (R: shared, multi-valued `applies_to_target`).
- §18.1 Q2 — i18n label for "Worauf richtet sich die Berufung?" (R: yes, defer to coder).
- §18.3 — Should event_categories.party be tightened in the same migration? (R: no, separate follow-up.)
No `AskUserQuestion` per inventor protocol; head escalates to m if material.
---
*End of design doc.* *End of design doc.*