feat(t-paliad-265): migration 129 + EventChoiceService (Slice A foundation)

m/paliad#96 — per-event-card optional choices on the Verfahrensablauf
timeline. This commit lands the schema + service layer.

Migration 129:
- paliad.project_event_choices table (project_id, submission_code,
  choice_kind ∈ {appellant, include_ccr, skip}, choice_value) with
  UNIQUE(project_id, submission_code, choice_kind) for idempotent
  re-pick, RLS via paliad.can_see_project.
- paliad.deadline_rules.choices_offered jsonb — opt-in declaration of
  which choice-kinds each rule offers. Seeded for every decision rule
  (appellant), every priority='optional' rule (skip), and the two
  Klageerwiderung rules (upc.inf.cfi.sod + de.inf.lg.erwidg) with
  include_ccr.

Live verification before authoring:
- rule_code is NULL on every decision row → submission_code is the
  join key (matches AnchorOverrides plumbing in fristenrechner.go).
- upc.inf.cfi.sod is the UPC Klageerwiderung, not upc.inf.cfi.def
  (rejected the design doc's first guess; SELECT name ILIKE
  'Klageerwiderung' confirmed).

Go service:
- models.ProjectEventChoice + DeadlineRule.ChoicesOffered.
- EventChoiceService: ListForProject / Upsert (with audit-log row to
  paliad.system_audit_log) / Delete. Pure-helper ToCalcOptionsAddendum
  + per-kind value validation + unit tests.

Design: docs/design-event-card-choices-2026-05-25.md §3 + §6.
This commit is contained in:
mAi
2026-05-25 16:45:07 +02:00
parent 169ace5d26
commit dc47ea7f43
6 changed files with 537 additions and 1 deletions

View File

@@ -0,0 +1,11 @@
-- t-paliad-265 — drop per-event-card choices schema.
SELECT set_config(
'paliad.audit_reason',
'mig 129 down: drop project_event_choices + deadline_rules.choices_offered',
true);
DROP TABLE IF EXISTS paliad.project_event_choices;
ALTER TABLE paliad.deadline_rules
DROP COLUMN IF EXISTS choices_offered;

View File

@@ -0,0 +1,116 @@
-- t-paliad-265 / m/paliad#96 — per-event-card optional choices on the
-- Verfahrensablauf timeline.
--
-- Design: docs/design-event-card-choices-2026-05-25.md
-- Decisions: see §11 of the design doc.
--
-- Two schema changes:
--
-- 1. paliad.project_event_choices — new persistence table holding the
-- user's per-card picks scoped to a project. One row per
-- (project, submission_code, choice_kind). Re-picking is an UPDATE
-- (UNIQUE constraint enforces idempotence).
--
-- 2. paliad.deadline_rules.choices_offered jsonb — opt-in declaration
-- of which choice-kinds each rule offers. The projection engine
-- reads this to decide whether to render the caret affordance on
-- a card. Seeded for every event_type='decision' rule (appellant),
-- every priority='optional' rule (skip), and the two Klageerwiderung
-- rows (include_ccr).
--
-- NOTE on join key: the design doc named the join column "rule_code".
-- Live verification (2026-05-25 SELECT against paliad.deadline_rules)
-- showed `rule_code` is NULL on every decision row — it's the legal-
-- source citation column, not a stable identifier. The
-- AnchorOverrides plumbing in internal/services/fristenrechner.go
-- already keys on `submission_code` (UIDeadline.Code populates from
-- submission_code, lines 351-352), so we mirror that decision here:
-- the join column is `submission_code`. Same intent, correct field.
--
-- Idempotent: CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS +
-- UPDATEs guarded by WHERE choices_offered IS NULL so re-applying
-- against an already-seeded DB no-ops.
SELECT set_config(
'paliad.audit_reason',
'mig 129: add paliad.project_event_choices + deadline_rules.choices_offered for per-event-card optional choices (t-paliad-265 / m/paliad#96)',
true);
-- 1. The choice-storage table ----------------------------------------------
CREATE TABLE IF NOT EXISTS paliad.project_event_choices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
submission_code text NOT NULL,
choice_kind text NOT NULL CHECK (choice_kind IN ('appellant', 'include_ccr', 'skip')),
choice_value text NOT NULL,
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(),
UNIQUE (project_id, submission_code, choice_kind)
);
CREATE INDEX IF NOT EXISTS project_event_choices_project_idx
ON paliad.project_event_choices (project_id);
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS project_event_choices_select ON paliad.project_event_choices;
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
FOR SELECT USING (paliad.can_see_project(project_id));
DROP POLICY IF EXISTS project_event_choices_mutate ON paliad.project_event_choices;
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));
COMMENT ON TABLE paliad.project_event_choices IS
'Per-event-card user picks scoped to a project. choice_kind ∈ {appellant, include_ccr, skip}. '
'choice_value namespace per kind: appellant=claimant|defendant|both|none; include_ccr=true|false; '
'skip=true|false. Join key submission_code matches paliad.deadline_rules.submission_code (the same key '
'AnchorOverrides uses). UNIQUE(project,submission_code,kind) keeps re-picks idempotent. '
'Audit-logged via paliad.system_audit_log (event_type=project_event_choice.set).';
-- 2. The choices_offered opt-in column ------------------------------------
ALTER TABLE paliad.deadline_rules
ADD COLUMN IF NOT EXISTS choices_offered jsonb;
COMMENT ON COLUMN paliad.deadline_rules.choices_offered IS
'Declares which per-card choice-kinds this rule offers on the Verfahrensablauf timeline. '
'NULL = no caret affordance (default). Example shapes: '
'{"appellant": ["claimant","defendant","both","none"]} on decision rules, '
'{"skip": [true, false]} on optional rules, '
'{"include_ccr": [true, false]} on Klageerwiderung rules. '
'Engine and frontend read it; storing per-kind value lists keeps the contract self-describing.';
-- 3. Seed -----------------------------------------------------------------
-- 3a. Every published decision rule offers the appellant choice.
UPDATE paliad.deadline_rules
SET choices_offered = '{"appellant": ["claimant", "defendant", "both", "none"]}'::jsonb
WHERE event_type = 'decision'
AND lifecycle_state = 'published'
AND choices_offered IS NULL;
-- 3b. Every published optional rule offers the skip choice.
UPDATE paliad.deadline_rules
SET choices_offered = '{"skip": [true, false]}'::jsonb
WHERE priority = 'optional'
AND lifecycle_state = 'published'
AND choices_offered IS NULL;
-- 3c. Klageerwiderung rules offer the include_ccr choice. Two rows
-- today (upc.inf.cfi.sod + de.inf.lg.erwidg) — verified live
-- (2026-05-25 SELECT FROM paliad.deadline_rules WHERE name ILIKE
-- 'Klageerwiderung'); the UPC INF Klageerwiderung is `sod` (Statement
-- of Defence, R.24 RoP), not `def`. Slice B (Q4 bundle) is the
-- user-visible feature.
UPDATE paliad.deadline_rules
SET choices_offered = '{"include_ccr": [true, false]}'::jsonb
WHERE submission_code IN ('upc.inf.cfi.sod', 'de.inf.lg.erwidg')
AND lifecycle_state = 'published'
AND choices_offered IS NULL;

View File

@@ -682,6 +682,13 @@ type DeadlineRule struct {
// NULL while draft, set on publish, retained through archive.
// Distinct from UpdatedAt (moves on every edit).
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
// ChoicesOffered declares which per-event-card choice-kinds this
// rule offers on the Verfahrensablauf timeline (mig 129,
// t-paliad-265). NULL = no caret affordance (default). See the
// COMMENT on paliad.deadline_rules.choices_offered for the value
// shape. The engine and the frontend both read this column.
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
}
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
@@ -946,3 +953,24 @@ type ApprovalRequest struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ProjectEventChoice is one per-event-card pick scoped to a project
// (t-paliad-265 / m/paliad#96). The join key SubmissionCode matches
// paliad.deadline_rules.submission_code — the same identifier the
// AnchorOverrides plumbing in fristenrechner.go already uses.
//
// ChoiceKind ∈ {appellant, include_ccr, skip}. ChoiceValue namespace
// per kind: appellant=claimant|defendant|both|none; include_ccr=true|false;
// skip=true|false. UNIQUE(project_id, submission_code, choice_kind)
// makes re-picks idempotent (Upsert path).
type ProjectEventChoice struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
SubmissionCode string `db:"submission_code" json:"submission_code"`
ChoiceKind string `db:"choice_kind" json:"choice_kind"`
ChoiceValue string `db:"choice_value" json:"choice_value"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -34,7 +34,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
created_at, updated_at,
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
priority, is_court_set, lifecycle_state, draft_of, published_at`
priority, is_court_set, lifecycle_state, draft_of, published_at,
choices_offered`
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active`

View File

@@ -0,0 +1,272 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// EventChoiceService reads and writes paliad.project_event_choices —
// per-event-card user picks scoped to a project (t-paliad-265 /
// m/paliad#96). Three choice kinds today:
//
// appellant — claimant | defendant | both | none
// include_ccr — true | false
// skip — true | false
//
// Visibility follows paliad.can_see_project (via ProjectService.CanSee).
// Audits via paliad.system_audit_log with event_type=project_event_choice.set
// (insert/update) or .deleted (delete).
//
// The CRUD surface is intentionally tight: List for a project (one read),
// Upsert one (idempotent re-pick), Delete one (kind-scoped). The
// projection engine receives the choices via ToCalcOptionsAddendum,
// which folds them into CalcOptions before Calculate runs.
type EventChoiceService struct {
db *sqlx.DB
projects *ProjectService
users *UserService
}
func NewEventChoiceService(db *sqlx.DB, projects *ProjectService, users *UserService) *EventChoiceService {
return &EventChoiceService{db: db, projects: projects, users: users}
}
// Allowed choice kinds + per-kind value namespaces. Validated server-side
// before any write; the DB CHECK constraint catches the same shape but
// the early validation gives a friendlier error and short-circuits the
// transaction.
var (
allowedChoiceKinds = map[string]map[string]struct{}{
"appellant": {"claimant": {}, "defendant": {}, "both": {}, "none": {}},
"include_ccr": {"true": {}, "false": {}},
"skip": {"true": {}, "false": {}},
}
)
func validateChoice(kind, value string) error {
values, ok := allowedChoiceKinds[kind]
if !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, kind)
}
if _, ok := values[value]; !ok {
return fmt.Errorf("%w: invalid choice_value %q for kind %q", ErrInvalidInput, value, kind)
}
return nil
}
// ListForProject returns every choice row for the given project. Caller
// must hold visibility on the project.
func (s *EventChoiceService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
out := []models.ProjectEventChoice{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at
FROM paliad.project_event_choices
WHERE project_id = $1
ORDER BY submission_code, choice_kind`, projectID)
if err != nil {
return nil, fmt.Errorf("list event choices: %w", err)
}
return out, nil
}
// UpsertInput is the body shape for an upsert.
type UpsertEventChoiceInput struct {
SubmissionCode string `json:"submission_code"`
ChoiceKind string `json:"choice_kind"`
ChoiceValue string `json:"choice_value"`
}
// Upsert inserts or updates one (project, submission_code, choice_kind)
// row. Audit-log row written in the same tx.
func (s *EventChoiceService) Upsert(ctx context.Context, userID, projectID uuid.UUID, input UpsertEventChoiceInput) (*models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
if input.SubmissionCode == "" {
return nil, fmt.Errorf("%w: submission_code required", ErrInvalidInput)
}
if err := validateChoice(input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
actorEmail, err := s.actorEmail(ctx, userID)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.set ('||$1||','||$2||','||$3||')', true)`,
input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, fmt.Errorf("set audit reason: %w", err)
}
var row models.ProjectEventChoice
err = tx.GetContext(ctx, &row,
`INSERT INTO paliad.project_event_choices
(project_id, submission_code, choice_kind, choice_value, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $5)
ON CONFLICT (project_id, submission_code, choice_kind)
DO UPDATE SET choice_value = EXCLUDED.choice_value,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at`,
projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue, userID)
if err != nil {
return nil, fmt.Errorf("upsert event choice: %w", err)
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.set", userID, actorEmail, projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit upsert: %w", err)
}
return &row, nil
}
// Delete removes the (project, submission_code, choice_kind) row.
// Returns ErrNotVisible if the project isn't visible OR the row didn't
// exist (no leak between the two).
func (s *EventChoiceService) Delete(ctx context.Context, userID, projectID uuid.UUID, submissionCode, choiceKind string) error {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return err
}
if submissionCode == "" || choiceKind == "" {
return fmt.Errorf("%w: submission_code + choice_kind required", ErrInvalidInput)
}
if _, ok := allowedChoiceKinds[choiceKind]; !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, choiceKind)
}
actorEmail, err := s.actorEmail(ctx, userID)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.deleted ('||$1||','||$2||')', true)`,
submissionCode, choiceKind); err != nil {
return fmt.Errorf("set audit reason: %w", err)
}
res, err := tx.ExecContext(ctx,
`DELETE FROM paliad.project_event_choices
WHERE project_id = $1 AND submission_code = $2 AND choice_kind = $3`,
projectID, submissionCode, choiceKind)
if err != nil {
return fmt.Errorf("delete event choice: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotVisible
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.deleted", userID, actorEmail, projectID, submissionCode, choiceKind, ""); err != nil {
return err
}
return tx.Commit()
}
// CalcOptionsAddendum is the per-card slice of CalcOptions, built from
// the persisted choices. ProjectionService folds these into the parent
// CalcOptions before Calculate runs.
type CalcOptionsAddendum struct {
PerCardAppellant map[string]string // submission_code → appellant value
SkipRules map[string]struct{} // set of submission_code
IncludeCCRFor map[string]struct{} // set of submission_code
}
// ToCalcOptionsAddendum converts a list of choices into the calc-options
// shape. Empty input yields an addendum whose maps are non-nil but empty
// so callers can use map indexing without nil checks.
func ToCalcOptionsAddendum(choices []models.ProjectEventChoice) CalcOptionsAddendum {
out := CalcOptionsAddendum{
PerCardAppellant: map[string]string{},
SkipRules: map[string]struct{}{},
IncludeCCRFor: map[string]struct{}{},
}
for _, c := range choices {
switch c.ChoiceKind {
case "appellant":
out.PerCardAppellant[c.SubmissionCode] = c.ChoiceValue
case "skip":
if c.ChoiceValue == "true" {
out.SkipRules[c.SubmissionCode] = struct{}{}
}
case "include_ccr":
if c.ChoiceValue == "true" {
out.IncludeCCRFor[c.SubmissionCode] = struct{}{}
}
}
}
return out
}
// writeChoiceAudit inserts a project-scoped row into paliad.system_audit_log
// with the choice details in metadata. Same shape as the data-export +
// checklist audit writers.
func writeChoiceAudit(ctx context.Context, tx *sqlx.Tx, eventType string, actorID uuid.UUID, actorEmail string, projectID uuid.UUID, submissionCode, choiceKind, choiceValue string) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ($1, $2, $3, 'project', $4,
jsonb_build_object(
'submission_code', $5::text,
'choice_kind', $6::text,
'choice_value', $7::text
))`,
eventType, actorID, actorEmail, projectID, submissionCode, choiceKind, choiceValue); err != nil {
return fmt.Errorf("audit insert: %w", err)
}
return nil
}
func (s *EventChoiceService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
var email string
err := s.db.GetContext(ctx, &email,
`SELECT email FROM paliad.users WHERE id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotVisible
}
if err != nil {
return "", fmt.Errorf("lookup actor: %w", err)
}
return email, nil
}
func (s *EventChoiceService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
visible, err := s.projects.CanSee(ctx, userID, projectID)
if err != nil {
return err
}
if !visible {
return ErrNotVisible
}
return nil
}

View File

@@ -0,0 +1,108 @@
package services
import (
"testing"
"mgit.msbls.de/m/paliad/internal/models"
)
// Unit tests for the pure helpers in event_choice_service.go. The CRUD
// path needs a live DB and lives in the integration suite.
func TestValidateChoice_Appellant(t *testing.T) {
for _, value := range []string{"claimant", "defendant", "both", "none"} {
if err := validateChoice("appellant", value); err != nil {
t.Errorf("appellant=%q should pass, got %v", value, err)
}
}
for _, bad := range []string{"", "applicant", "true", "claimaant"} {
if err := validateChoice("appellant", bad); err == nil {
t.Errorf("appellant=%q should fail validation", bad)
}
}
}
func TestValidateChoice_IncludeCCR(t *testing.T) {
for _, value := range []string{"true", "false"} {
if err := validateChoice("include_ccr", value); err != nil {
t.Errorf("include_ccr=%q should pass, got %v", value, err)
}
}
for _, bad := range []string{"", "yes", "1", "True"} {
if err := validateChoice("include_ccr", bad); err == nil {
t.Errorf("include_ccr=%q should fail validation", bad)
}
}
}
func TestValidateChoice_Skip(t *testing.T) {
for _, value := range []string{"true", "false"} {
if err := validateChoice("skip", value); err != nil {
t.Errorf("skip=%q should pass, got %v", value, err)
}
}
if err := validateChoice("skip", "maybe"); err == nil {
t.Errorf("skip=maybe should fail")
}
}
func TestValidateChoice_UnknownKind(t *testing.T) {
if err := validateChoice("not_a_kind", "true"); err == nil {
t.Errorf("unknown choice_kind should fail")
}
}
func TestToCalcOptionsAddendum_PerCardAppellant(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.decision", ChoiceKind: "appellant", ChoiceValue: "defendant"},
{SubmissionCode: "de.inf.lg.urteil", ChoiceKind: "appellant", ChoiceValue: "both"},
}
out := ToCalcOptionsAddendum(choices)
if out.PerCardAppellant["upc.inf.cfi.decision"] != "defendant" {
t.Errorf("appellant pick for upc.inf.cfi.decision = %q, want defendant", out.PerCardAppellant["upc.inf.cfi.decision"])
}
if out.PerCardAppellant["de.inf.lg.urteil"] != "both" {
t.Errorf("appellant pick for de.inf.lg.urteil = %q, want both", out.PerCardAppellant["de.inf.lg.urteil"])
}
if len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
t.Errorf("appellant-only input should not populate skip/include_ccr maps")
}
}
func TestToCalcOptionsAddendum_SkipRules(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.ccr", ChoiceKind: "skip", ChoiceValue: "true"},
{SubmissionCode: "upc.inf.cfi.prelim", ChoiceKind: "skip", ChoiceValue: "false"},
}
out := ToCalcOptionsAddendum(choices)
if _, ok := out.SkipRules["upc.inf.cfi.ccr"]; !ok {
t.Errorf("skip=true should populate SkipRules")
}
if _, ok := out.SkipRules["upc.inf.cfi.prelim"]; ok {
t.Errorf("skip=false should NOT populate SkipRules")
}
}
func TestToCalcOptionsAddendum_IncludeCCRFor(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.sod", ChoiceKind: "include_ccr", ChoiceValue: "true"},
{SubmissionCode: "de.inf.lg.erwidg", ChoiceKind: "include_ccr", ChoiceValue: "false"},
}
out := ToCalcOptionsAddendum(choices)
if _, ok := out.IncludeCCRFor["upc.inf.cfi.sod"]; !ok {
t.Errorf("include_ccr=true should populate IncludeCCRFor")
}
if _, ok := out.IncludeCCRFor["de.inf.lg.erwidg"]; ok {
t.Errorf("include_ccr=false should NOT populate IncludeCCRFor")
}
}
func TestToCalcOptionsAddendum_EmptyInput(t *testing.T) {
out := ToCalcOptionsAddendum(nil)
if out.PerCardAppellant == nil || out.SkipRules == nil || out.IncludeCCRFor == nil {
t.Errorf("empty input should still produce non-nil maps for safe indexing")
}
if len(out.PerCardAppellant) != 0 || len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
t.Errorf("empty input should produce empty maps")
}
}