Files
paliad/docs/design-event-card-choices-2026-05-25.md
mAi 169ace5d26 design(t-paliad-265): fold m's decisions into the design doc
Four open questions answered via AskUserQuestion (2026-05-25):

  Q1 State location       → persisted table          (matches (R))
  Q2 Affordance           → caret + popover          (matches (R))
  Q3 Appellant layer      → per-card overrides       (matches (R))
  Q4 Slice order          → bundle A + B             (over (R) of "A first")

Q4 captured with rationale: cohesive PR, single user-visible release,
no half-shipped state where the include-CCR popover would exist
without the engine wire-through. Coder still organises commits per
slice internally; one branch, one ship.
2026-05-25 16:27:30 +02:00

33 KiB
Raw Permalink Blame History

Design — Per-event-card optional choices on the Verfahrensablauf timeline

Author: atlas (inventor) Date: 2026-05-25 Task: t-paliad-265 (m/paliad#96) Branch: mai/atlas/inventor-per-event-card Status: READY FOR REVIEW — m gates inventor → coder transition.


0. TL;DR

m's decisions landed 2026-05-25 — see §11. Persisted table, caret+popover, per-card-overrides-page-level, and m chose to bundle Slice A + Slice B into one coder shift (over the inventor (R) of "Slice A first"). All other picks matched inventor recommendations.

The Verfahrensablauf timeline today carries two projection knobs at the page level — side (who-we-are) and appellant (who-initiated). Both are global for the whole timeline. m wants three more knobs, but per event card, not page-level:

  1. Appellant per decision card — if a decision is appealable, the user picks which side appealed (Claimant / Defendant / Both / None). Different decisions in the same timeline can have different appellants.
  2. Include Nichtigkeitswiderklage on Klageerwiderung — toggling this on a single Klageerwiderung card flips on the existing with_ccr flag for everything downstream of that card.
  3. Skip an optional event — for any rule marked priority='optional', a per-card "don't consider for this case" toggle hides downstream consequences.

The flow these choices drive is already therecondition_expr jsonb gates (with_ccr, with_amend, with_cci) plus the page-level appellant selector. What's missing is (a) per-card scope and (b) per-project persistence.

Recommendation: persist choices in a new paliad.project_event_choices table; expose them through a popover-on-caret affordance on the relevant cards only; map them into the existing CalcOptions.Flags + a new per-rule Appellants map at projection time. Two slices: Slice A (appellant-per-decision + skip-optional, narrow + bounded), Slice B (include-CCR-on-Klageerwiderung, requires per-card flag-scoping in the projection engine — bigger).


1. Premises verified live (before designing)

CLAUDE.md / memory / issue text can drift; the live system can't. Each load-bearing premise below was probed against the live DB or live source on 2026-05-25.

Schema

  • Migration tracker at 127 (paliad.paliad_schema_migrations). Next migration: 128. No new table for project_event_choices exists today.
  • paliad.deadline_rules carries condition_expr jsonb already. The flag-evaluation engine (internal/services/fristenrechner.go:208 Calculate, evalConditionExpr at line ~947) walks the jsonb tree and skips rules whose gate is unsatisfied. Today's gates are {"flag":"with_ccr"}, {"flag":"with_amend"}, {"flag":"with_cci"}, and {"op":"and","args":[…]} combinations.
  • with_ccr is the existing Nichtigkeitswiderklage gate. Verified live: 7 upc.inf.cfi rules gate on it (upc.inf.cfi.reply, …rejoin, …ccr, …def_to_ccr, …reply_def_ccr, …rejoin_reply_ccr, plus upc.inf.cfi.app_to_amend which additionally requires with_amend).
  • priority column has 4 values: mandatory, recommended, optional, informational. Live counts (deadline_rules table-wide): 230 mandatory / 18 recommended / 6 optional / (informational not in count, must be 0 or absent). The "skip optional" affordance keys off priority='optional'.
  • event_type discriminator exists with values filing, decision, hearing. The "appellant-per-decision" affordance keys off event_type='decision'. Live: every decision rule has primary_party='court'.
  • paliad.projects.our_side exists (column added before mig 112; values today include claimant|defendant|applicant|appellant|respondent|third_party|other). It is the broad project-level side axis t-paliad-257 / #88 hooked into.
  • NO appellant column on paliad.projects — the appellant axis lives only in the URL query (?appellant=claimant|defendant) in client/verfahrensablauf.ts:73-89.

Frontend

  • frontend/src/client/views/verfahrensablauf-core.ts is the shared rendering core for both /tools/verfahrensablauf and /tools/fristenrechner. Per-card UI affordances added here surface on both pages automatically.
  • bucketDeadlinesIntoColumns(deadlines, {side, appellant}) (line 496) is the pure routing primitive; column placement is computed without DOM. Unit-tested in verfahrensablauf-core.test.ts.
  • deadlineCardHtml(dl, {showParty, editable, showNotes}) (line 254) is the per-card renderer. There is no per-card props channel for "choices" yet — that's the surface this design extends.
  • client/verfahrensablauf.ts and client/fristenrechner.ts both manage currentSide + currentAppellant in-memory and round-trip them through the URL (writeSideToURL / writeAppellantToURL). The pattern is mature; this design mirrors it for the new state when state stays URL-bound, and lifts it into a server-persisted store when state stays per-project.
  • APPELLANT_AXIS_PROCEEDINGS set (verfahrensablauf.ts:52-62) gates the page-level appellant selector to appeal-flavoured proceedings only. The per-card appellant affordance MUST NOT depend on this set — any first-instance decision is a potential appeal trigger (e.g. LG-Urteil → Berufung, BPatG-Entscheidung → BGH-Rechtsbeschwerde).

Surfaces in scope

  • /tools/verfahrensablauf — abstract browse, no project context. Per-card choices here are ephemeral (URL-bound) — there's no project to persist into.
  • /tools/fristenrechner — concrete projection, optionally project-bound via ?project=<id> (currentStep1Context.kind === "project"). When project-bound, per-card choices persist to paliad.project_event_choices. When unbound, URL only.
  • /projects/{id} Verlauf tab (SmartTimeline) — separate widget (per docs/design-smart-timeline-2026-05-08.md); does NOT use renderColumnsBody. Per-card choices are NOT in scope for the SmartTimeline in v1 — the Verfahrensablauf core is.

What is NOT premised

  • The deadline_rules → procedural_events rename (#93) is not assumed shipped. This design uses deadline_rules/rule_code vocabulary throughout and flags the rename touch-points in §6.
  • The per-card UI does NOT require new server-side priority/event_type semantics. Both priority='optional' and event_type='decision' exist on every row.

2. Vision + scope

m's vision (verbatim 2026-05-25 15:12):

We still have no choice to say that a specific party appealed. We may need selections within the event cards on the timeline to change it? For example for a decision we could check Appeal by... or in Klageerwiderung we can chose to include a Nichtigkeitswiderklage. Or with any optional event we can select not to consider it (because someone decided not to file it).

What changes

  • A caret affordance (▾) appears on the right edge of cards that have at least one applicable choice-kind. Click → small popover with the choices. Cards without an applicable choice render unchanged.
  • A choices_offered jsonb column on paliad.deadline_rules declares which choice-kinds each rule offers. Three kinds in v1:
    • appellant — applicable to rules with event_type='decision' (no static list; engine decides).
    • include_ccr — applicable to the single Klageerwiderung rule per proceeding (today: upc.inf.cfi.def, de.inf.lg.erwidg).
    • skip — applicable to any rule with priority='optional'.
  • A new persistence table paliad.project_event_choices(project_id, rule_code, choice_kind, choice_value) holds the user's choices. Per-project, audit-logged via paliad.system_audit_log.
  • A projection-time merge turns the persisted choices into CalcOptions.Flags and a new PerCardAppellants map[ruleCode]string field, then re-runs the existing projection engine. No new flag types; with_ccr is the same with_ccr.

What stays

  • bucketDeadlinesIntoColumns and renderColumnsBody are extended (new opts), not replaced.
  • condition_expr jsonb gating semantics are unchanged. Per-card include_ccr choice simply means "set with_ccr in the flag set for this projection" — same engine.
  • Page-level side / appellant selectors stay. The per-card appellant choice is an override layer on top of the page-level appellant (Q4 below).
  • URL-state plumbing (?side=…, ?appellant=…) stays. The page-level URL params remain the only state for unbound /tools/verfahrensablauf.

Out of scope (v1)

  • Per-card choices on the SmartTimeline (project Verlauf tab). Deferred to a follow-up when SmartTimeline matures.
  • Versioning of choices over time ("the appellant changed mid-case", "the CCR was withdrawn"). Choices are last-write-wins.
  • Cross-project propagation of choices.
  • Implementing the choice flow (coder task per slice; this is design-only).
  • A "what-if scenarios" mode (saved named scenarios).

3. Data model

3.1 The new table

-- migration 128_project_event_choices.up.sql
CREATE TABLE paliad.project_event_choices (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id  uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
  rule_code   text NOT NULL,                       -- e.g. "RoP.029.a" or "de.inf.lg.urteil"
  choice_kind text NOT NULL,                       -- 'appellant' | 'include_ccr' | 'skip'
  choice_value text NOT NULL,                      -- value namespace per kind (see §3.3)
  created_by  uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_by  uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
  updated_at  timestamptz NOT NULL DEFAULT now(),

  -- One choice per (project, rule_code, kind). Re-pick is an UPDATE.
  UNIQUE (project_id, rule_code, choice_kind)
);

CREATE INDEX project_event_choices_project_idx
  ON paliad.project_event_choices (project_id);

-- RLS: same `paliad.can_see_project(project_id)` predicate as paliad.deadlines.
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
  FOR SELECT USING (paliad.can_see_project(project_id));
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
  FOR ALL USING (paliad.can_see_project(project_id))
  WITH CHECK (paliad.can_see_project(project_id));

Why this shape:

  • Tall not wide — adding a 4th choice-kind in slice C means one more allowed choice_kind value, no DDL.
  • rule_code is the join key against paliad.deadline_rules (which already uses rule_code widely — Calculate, AnchorOverrides, the projection). Stable across rule renames provided the rename keeps the same rule_code.
  • UNIQUE per (project, rule_code, kind) makes the choice idempotent — re-picking the appellant overwrites, doesn't accumulate.
  • ON DELETE CASCADE follows the project — when a project is hard-deleted (rare; usually soft-status), the choices go with it.

3.2 The opt-in column on paliad.deadline_rules

-- migration 128_project_event_choices.up.sql (same migration)
ALTER TABLE paliad.deadline_rules
  ADD COLUMN choices_offered jsonb;

-- Example seeded values (in the same migration's data-fix block):
--
--   upc.inf.cfi.def → '{"include_ccr": [true, false]}'
--   de.inf.lg.erwidg → '{"include_ccr": [true, false]}'
--   upc.inf.cfi.decision → '{"appellant": ["claimant", "defendant", "both", "none"]}'
--   de.inf.lg.urteil → '{"appellant": ["claimant", "defendant", "both", "none"]}'
--   (every event_type='decision' rule)
--   upc.inf.cfi.ccr (priority='optional') → '{"skip": [true, false]}'
--   (every priority='optional' rule)

Alternative considered + rejected: infer offering at projection-time from (event_type, priority, submission_code) heuristics. Rejected because:

  • The Klageerwiderung rule is identified only by its submission_code slug. Tying the engine to a hardcoded slug list inside the projection service is brittle (mig 124 + future Wave-1 fixes rename slugs); declaring choices_offered in data lets the audit ship them without a code change.
  • A skip toggle that's automatically derived from priority='optional' is consistent today but may diverge tomorrow (an optional rule we DON'T want skippable, or a non-optional rule we DO want skippable). The opt-in jsonb keeps the choice axis decoupled from priority.

3.3 Value namespaces per kind

choice_kind choice_value valid set Default when no row exists
appellant "claimant" / "defendant" / "both" / "none" inherits page-level appellant (URL ?appellant=), else null (treated as "not yet picked" — render appeal-deadlines greyed)
include_ccr "true" / "false" "false" (no CCR until user opts in — matches current default flag set)
skip "true" / "false" "false" (rule renders normally)

Values are stored as text not boolean so the same column scales to multi-valued kinds (appellant has 4 values; future kinds may have N). Coercion lives in the service layer.

3.4 Audit trail

Every INSERT / UPDATE / DELETE on project_event_choices writes a row to paliad.system_audit_log (the standard sink mig 102 introduced) with event_type='project_event_choice.set' and the changed (rule_code, kind, value) in metadata jsonb. Pattern mirrors paliad.deadlines.status_changed audit rows.


4. Projection flow

The existing projection engine is a single Go function: FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions). Two changes:

4.1 Extending CalcOptions

type CalcOptions struct {
    // ...existing fields...
    Flags           []string             // <-- already exists
    AnchorOverrides map[string]string    // <-- already exists

    // NEW — per-card overrides surfaced by the per-event-card choices.
    // Keyed by deadline_rules.rule_code.
    //
    //   PerCardAppellant: when a decision rule's rule_code is in this map,
    //     the appellant for downstream rules whose parent is THAT decision
    //     is set to the value here. Overrides any global Appellant.
    //
    //   SkipRules: when a rule's rule_code is in this set, the rule is
    //     suppressed AND its descendants are suppressed. Same suppression
    //     path as a failed condition_expr gate.
    //
    //   IncludeCCRFor: when a rule's rule_code is in this set, the with_ccr
    //     flag is treated as set in the flag context FROM that rule
    //     onward (i.e. for that rule's descendants). On v1 with a single
    //     Klageerwiderung-per-proceeding, this is equivalent to a project-
    //     wide with_ccr — but the per-card scope leaves room for future
    //     proceedings with multiple CCR entry points.
    PerCardAppellant map[string]string   // rule_code → "claimant"|"defendant"|"both"|"none"
    SkipRules        map[string]struct{} // set of rule_code
    IncludeCCRFor    map[string]struct{} // set of rule_code
}

The handler reads project_event_choices for the project (if project-bound) and folds them into these fields before calling Calculate. When called unbound (URL-only, /tools/verfahrensablauf without project), the maps come from URL params instead (see §5.2).

4.2 Three engine changes

  1. SkipRules suppression: in the post-condition_expr filter pass (Calculate around line 333 where the gate is evaluated), additionally drop any rule whose rule_code ∈ opts.SkipRules. Also drop its descendants (existing parent_id walk already handles cascading; just add the new predicate to the keep/drop decision).

  2. IncludeCCRFor scope: rather than threading a per-rule flag context (expensive change to engine), implement v1 as: if any rule_code in IncludeCCRFor exists at all, append "with_ccr" to opts.Flags before the gate-evaluation pass. This is correct for the v1 surface (Klageerwiderung is the only CCR-entry-point per proceeding) but loses the per-card scoping for multi-CCR cases. The full per-rule scope is Slice B (§7).

  3. PerCardAppellant routing: when bucketDeadlinesIntoColumns collapses party=both rows in the appellant's column, today it consults the global opts.appellant. Extend to consult PerCardAppellant[ruleCode] first — if present, that drives the collapse for descendants of that decision. Out-of-band: this changes the projection contract subtly. We surface this as server-computed metadata on the response (CalculatedDeadline.AppellantContext) so the frontend bucketer doesn't need to know about parent-chain walks — the server already does the walk.

4.3 Wire shape

The CalculatedDeadline Go struct + TS mirror grow one optional field:

type CalculatedDeadline struct {
    // ...existing fields...
    AppellantContext string `json:"appellantContext,omitempty"`
    // "claimant" | "defendant" | "both" | "none" | "" (default).
    // Filled by the projection from the user's per-decision choice.
    // Frontend bucketer prefers this over the page-level appellant.
}

This keeps the bucketer logic local — no second pass needed.


5. UI / i18n

5.1 Caret + popover affordance

Each rendered card gets, when choices_offered IS NOT NULL, a caret on the right edge of the title line. Click → popover anchored to the caret. Popover renders one block per choice-kind the rule offers (typically one, occasionally two if a rule has both appellant and skip — none today; design holds for the future).

DOM-wise: frontend/src/client/views/verfahrensablauf-core.ts deadlineCardHtml grows a choicesCaret segment, and a sibling module client/views/event-card-choices.ts (new) owns the popover open/close + commit handler. The popover commits via POST /api/projects/{id}/event-choices with body {rule_code, kind, value}; the response is the updated choice row.

Why a popover and not inline checkboxes:

  • Inline would put a checkbox on every decision card + every optional card. ~6 decision cards + ~6 optional cards on a typical UPC.INF.CFI projection is ~12 always-on widgets per timeline. Visual noise + scan cost.
  • Popover defaults to hidden; the caret is a low-noise affordance. The selected choice surfaces as a small chip on the card title line ("Berufung: Beklagter") so the choice is glanceable without re-opening.
  • Mobile + touch: the caret is a 24×24 tap target; the popover is keyboard-dismissable.

Why not card-hover-reveal: discoverability + touch failure (no hover on iOS).

5.2 URL fallback (no project context)

When /tools/verfahrensablauf is opened without a project (the abstract-browse case), per-card choices have no persistence layer. The popover still works, but commits update an in-memory + URL state instead:

?event_choices=RoP.029.a:appellant=defendant,upc.inf.cfi.ccr:skip=true

Compact CSV in one URL param. Read at page load, applied to CalcOptions via the same PerCardAppellant / SkipRules / IncludeCCRFor route. Shareable, ephemeral. Matches the existing ?side= + ?appellant= URL idiom.

5.3 Chip indicators

A card with a non-default choice gets a small chip next to the title:

  • Appellant chosen: Berufung: Beklagter / Appeal: Defendant
  • Include CCR: mit Nichtigkeitswiderklage / with CCR
  • Skipped: card itself fades to 50% opacity, body adds class timeline-item--skipped, chip reads übersprungen / skipped with an undo arrow.

5.4 i18n keys (new)

choices.caret.title              "Optionen für dieses Ereignis"             "Options for this event"
choices.appellant.title          "Berufung durch ..."                       "Appealed by ..."
choices.appellant.claimant       "Klägerseite"                              "Claimant side"
choices.appellant.defendant      "Beklagtenseite"                           "Defendant side"
choices.appellant.both           "beide Parteien"                           "both parties"
choices.appellant.none           "keine Berufung"                           "no appeal"
choices.include_ccr.title        "Nichtigkeitswiderklage einbeziehen"       "Include nullity counterclaim"
choices.skip.title               "Für diese Akte überspringen"              "Skip for this case"
choices.skipped.chip             "übersprungen"                             "skipped"
choices.reset                    "Auswahl zurücksetzen"                     "Reset choice"

5.5 What's removed

The page-level appellant selector (URL ?appellant=) stays for non-decision proceedings (the Appeal-CoA case where the appellant axis is the whole-timeline framing, not a per-decision choice). But for first-instance proceedings (UPC.INF, DE.INF.LG, etc.), the appellant axis migrates from page-level to per-decision card. The page-level selector hides when the proceeding has decision rules with choices_offered.appellant declared — which is the cleaner UX (one knob, in the right place).


6. Services + handlers (new surface)

6.1 Go service

// internal/services/event_choice_service.go (new)
type EventChoiceService struct {
    db *sqlx.DB
}

func (s *EventChoiceService) ListForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectEventChoice, error)
func (s *EventChoiceService) Upsert(ctx context.Context, c ProjectEventChoice) error
func (s *EventChoiceService) Delete(ctx context.Context, projectID uuid.UUID, ruleCode, kind string) error

// Used by ProjectionService to fold choices into CalcOptions.
func (s *EventChoiceService) ToCalcOptions(choices []ProjectEventChoice) CalcOptionsAddendum

The CalcOptionsAddendum type wraps the three new map/set fields so the merge into the parent CalcOptions is one call from the projection handler.

6.2 HTTP routes

GET    /api/projects/{id}/event-choices              → []ProjectEventChoice
PUT    /api/projects/{id}/event-choices              → upsert one (body: {rule_code, kind, value})
DELETE /api/projects/{id}/event-choices/{rule_code}/{kind} → remove

All gated by gateOnboarded + visibilityPredicate (project-team membership).

6.3 Projection handler

The existing POST /api/tools/fristenrechner handler accepts flags, anchorOverrides, priorityDate, courtId. Extend the request shape:

{
  "proceedingType": "upc.inf.cfi",
  "triggerDate": "2026-01-15",
  "flags": ["with_ccr"],
  "perCardChoices": [
    {"rule_code": "RoP.029.a", "kind": "appellant", "value": "defendant"},
    {"rule_code": "upc.inf.cfi.ccr", "kind": "skip", "value": "true"}
  ]
}

Or, when project-bound:

{
  "proceedingType": "upc.inf.cfi",
  "triggerDate": "2026-01-15",
  "projectId": "abc-123"
  // server pulls perCardChoices from paliad.project_event_choices
}

The handler merges either source into CalcOptions and runs Calculate.

6.4 Touch points — files coder will edit

  • DB: new migration 128_project_event_choices.up.sql + .down.sql. Add choices_offered column + seed data.
  • Go: internal/services/event_choice_service.go (new), internal/services/fristenrechner.go (extend CalcOptions, projection logic), internal/handlers/event_choices.go (new HTTP routes), internal/handlers/fristenrechner.go (request shape extension).
  • Models: internal/models/models.goProjectEventChoice struct, CalculatedDeadline.AppellantContext field.
  • Frontend: frontend/src/client/views/verfahrensablauf-core.ts (caret + chip in deadlineCardHtml), frontend/src/client/views/event-card-choices.ts (new popover module), frontend/src/client/verfahrensablauf.ts + frontend/src/client/fristenrechner.ts (URL-state plumbing for the unbound case; load project choices for the bound case).
  • i18n: frontend/src/client/i18n.ts + frontend/src/i18n-keys.ts — new keys per §5.4.
  • Tests: internal/services/event_choice_service_test.go (new), internal/services/fristenrechner_test.go (extend with PerCardAppellant + SkipRules cases), frontend/src/client/views/verfahrensablauf-core.test.ts (extend bucketing with perCardAppellant opt).

6.5 Coordination with #93 procedural-events rename

When #93 lands (and the rename ships), this design's rule_code references become procedural_event.code — same string namespace, cleaner name. Join points:

  • project_event_choices.rule_codeproject_event_choices.procedural_event_code (or stays as a generic string column if #93 keeps rule_code as the join key).
  • deadline_rules.choices_offeredprocedural_events.choices_offered.

If #93 ships first, this design's migration applies to procedural_events instead. The data shape (jsonb + new join table) is unaffected. If THIS ships first, #93 absorbs the column in its rename.


7. Slice plan

Slice A — Appellant per decision + Skip optional event

Two choice-kinds, narrow + bounded, do not change the gate-evaluation engine.

  • DB: migration 128 adds project_event_choices + choices_offered. Seed choices_offered on all event_type='decision' rules and all priority='optional' rules.
  • Service: EventChoiceService CRUD; CalcOptions.PerCardAppellant + CalcOptions.SkipRules; Calculate extension to honour SkipRules suppression + AppellantContext metadata.
  • HTTP: 3 new routes (GET / PUT / DELETE on project_event_choices); fristenrechner request extension.
  • Frontend: caret + popover on decision cards + optional cards; chip indicators; URL-state for the unbound case; load-on-mount for the bound case.
  • Tests: bucketing with PerCardAppellant; service CRUD; gate-suppression with SkipRules.

Ship this slice first. It validates the popover affordance + the persistence layer end-to-end without touching the flag-evaluation engine.

Slice B — Include Nichtigkeitswiderklage on Klageerwiderung

Wires IncludeCCRFor through the flag-evaluation engine. v1 simplification (§4.2 #2) makes this almost a no-op for the engine — but the per-card scope semantics need a separate inventor pass to nail down whether the simplification holds for de.inf.lg's CCR analogue (Widerklage auf Nichtigkeit) and for any future proceedings with multiple CCR entry points.

  • DB: add include_ccr to allowed choice_kind values + seed choices_offered = '{"include_ccr": [true, false]}' on the Klageerwiderung rows (upc.inf.cfi.def, de.inf.lg.erwidg).
  • Service: CalcOptions.IncludeCCRFor; the "if non-empty, append with_ccr to Flags" simplification.
  • Frontend: the include_ccr popover block (already designed; just enabling the row).
  • Cross-flow audit: confirm that the existing 7 upc.inf.cfi cross-flow rules + de.inf.lg analogues fire correctly when with_ccr is set via the per-card path vs. the existing page-level flag checkbox. Existing checkbox stays in v1; deprecation is a Slice C decision.

Bundling note (per m's Q4 decision 2026-05-25)

A + B ship together. The slice headings above remain as a logical breakdown for the coder to follow when sequencing commits inside the single shift; they are not separate PRs. See §11 Q4 for rationale.

Slice C — Future choice-kinds

Open-ended; not designed here. Examples surfaced by the t-paliad-067 audit:

  • "Bilateral hearing requested" toggle on hearing rules.
  • "Cost orders requested" toggle on cost-related rules.
  • "Stay applied" toggle on procedural events.

Each new kind = one new allowed choice_kind value + one seed row + one popover block. Schema-stable.


8. Risk assessment

  • Migration risk: new table + new column, both additive. Down-migration drops table + column + reverts seed. No data loss path. Low risk.
  • Projection correctness: PerCardAppellant changes the bucket routing for "both" rows in chains downstream of a decision card. The unit-tested bucketDeadlinesIntoColumns carries the existing appellant semantics; extending it without breaking the existing test suite means new tests, not changes to existing ones. Coder MUST add the new tests before changing the bucketer.
  • Flag-context vs per-rule-flag aliasing: §4.2 #2 (Slice B) trades per-card precision for engine simplicity. Acceptable in v1 (Klageerwiderung is the only entry point per proceeding) but a known limitation. Document it in internal/services/fristenrechner.go doc comment so the next Wave-2 inventor doesn't think it's bug-free.
  • Page-level vs per-card appellant interaction: when both are set, per-card wins for descendants of the decision the per-card was set on; page-level still drives descendants of decisions without a per-card pick. Could confuse a user. Mitigation: the page-level appellant selector hides for first-instance proceedings (per §5.5). For appeal proceedings, the selector stays — but those proceedings have a single root decision so the conflict surface is small.
  • Cross-proceeding consistency (where #93's rename lives) — coordinate with the inventor on #93 if both ship in parallel.

9. Out of scope (recap)

  • SmartTimeline (project Verlauf tab) per-card choices.
  • Versioning / time-machine of choices.
  • Cross-project propagation.
  • Coder implementation (separate task per slice).
  • A "saved scenarios" feature.
  • Removal of the page-level ?appellant= URL param for appeal proceedings.

10. Open questions for m

The following 4 questions need m's pick. Inventor recommendations marked (R). After m answers via AskUserQuestion, the picks land in §11 below as the historical record.

Q1 — State location

Where do per-card choices live?

  • (R) A. paliad.project_event_choices persisted (with URL override for what-if). Per-case choices are real, not exploratory. Persist by default; what-if exploration handled later as a URL-override layer.
  • B. URL query state only. Ephemeral, shareable, no persistence.
  • C. Both from day one. Persisted default + URL-overridable for what-if scenarios.

Q2 — Affordance

How do the choices surface on a card?

  • (R) A. Caret (▾) + popover on click. Off-by-default visual, on-tap reveal. Selected choice surfaces as a chip on the card title.
  • B. Inline checkbox/radio on every relevant card. Higher discoverability, more visual noise.
  • C. Card-hover reveals the choices. Discoverability + touch issues.

Q3 — Page-level appellant interaction

When a per-card appellant is set on a decision, what happens to the page-level ?appellant= selector?

  • (R) A. Per-card overrides page-level for descendants of THAT decision. Decisions without a per-card pick still use page-level. Most expressive.
  • B. Per-card inherits page-level unless explicitly set. Less surprising default but loses the per-decision expressiveness.

Q4 — Slice order

Which slice ships first?

  • (R) A. Slice A first (appellant per decision + skip optional). Bounded, validates the popover + persistence layer without touching the flag-evaluation engine. Slice B (include-CCR) follows.
  • B. Slice B first. Higher-impact user feature but requires the engine change.
  • C. Bundle A + B in one coder shift. Slower to ship, lower per-coder load, but one less round trip.

11. m's decisions (2026-05-25)

  • Q1 (State location): Persisted table — paliad.project_event_choices per §3.1. Matches inventor (R).
  • Q2 (Affordance): Caret + popover with chip indicator on chosen cards per §5.1, §5.3. Matches inventor (R).
  • Q3 (Appellant layer): Per-card overrides page-level for descendants of that decision. Page-level still drives decisions without a per-card pick. Matches inventor (R). Implementation: CalculatedDeadline.AppellantContext (§4.3) carries the per-decision pick down the parent chain so the bucketer reads one field.
  • Q4 (Slice order): Bundle Slice A + Slice B in one coder shift (m picked over inventor (R) of "A first"). Reasoning: keeps the popover, persistence layer, AND the engine extension for IncludeCCRFor in one cohesive PR — coder + reviewer hold the full mental model once; one user-visible release; no half-shipped state where the caret exists on Klageerwiderung cards but the include-CCR pick doesn't yet wire through. Trade-off: larger PR. Mitigation: coder still organises commits per slice internally (separate test files, separate handler additions) so review can read them sequentially. See §7 slice plan — both slices implemented; ship as one.

Coder-shift implications of Q4 bundling

  • Migration 128 carries ALL three choice-kinds (appellant, skip, include_ccr) in the seed of choices_offered, plus the Klageerwiderung rows seeded with {"include_ccr": [true, false]}.
  • CalcOptions gains all three new fields (PerCardAppellant, SkipRules, IncludeCCRFor) in the same Go change.
  • The IncludeCCRFor v1 simplification (§4.2 #2 — "any non-empty set means append with_ccr to Flags") documents the per-card-scope limitation up front. Multi-CCR proceedings are a future expansion, not a v1 ship blocker.
  • Frontend popover renders all three blocks the rule offers in one render path; coder cannot half-ship by leaving include_ccr's popover branch as a TODO.
  • Tests cover the full matrix on the same branch.

12. Hard rules for the coder shift

  • Migration is 128, not anything else. Verify against paliad.paliad_schema_migrations MAX before authoring.
  • Tests added BEFORE projection-engine changes in fristenrechner.go (bucketer, gate, AppellantContext).
  • go build ./... && go test ./internal/... && cd frontend && bun run build clean.
  • No regression on ?side= + ?appellant= URL state.
  • DE primary, EN secondary for all new i18n keys.
  • Branch per slice: mai/<coder>/event-card-choices-slice-a etc.

13. Reporting

When ready, the coder reports completion with the URL of the test project that exercises the feature, a screenshot of the popover, and the deadline-rules SQL UPDATE counts for the seeded choices_offered rows. Standard slice-completion shape.