Compare commits

...

16 Commits

Author SHA1 Message Date
mAi
5cff38ff3c feat(deadlines): mig 138 backfill applies_to_target — Schadensbemessung (merits) + Bucheinsicht (order)
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
After Slice B1's Berufung unification (mig 134), the picker exposed
five appeal targets but only three carried rules. Schadensbemessung and
Bucheinsicht returned empty timelines.

m's 2026-05-26 decision (#134): R.224 is uniform across substantive
R.118 decisions, and R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are
uniform across the orders they appeal — so the existing merits-track
and order-track rules can carry the missing targets via a non-destructive
applies_to_target extension.

Audit of live `paliad.deadline_rules` for upc.apl.unified (proceeding_type_id=160):
- 7 endentscheidung rules → extend with 'schadensbemessung'
- 7 anordnung rules        → extend with 'bucheinsicht'
- 2 kostenentscheidung rules — untouched (distinct leave-to-appeal track)

Migration:
- set_config('paliad.audit_reason', …) at top of UP and DOWN — required
  by the mig 079 deadline_rule_audit_trigger on every UPDATE.
- Audit-first DO block lists every row to be touched (pre/post state)
  and RAISE EXCEPTIONs on pre-condition drift (missing proceeding_type,
  wrong rule counts, partial-run carry-over of the new targets).
- Two narrow UPDATEs keyed off upc.apl.unified + existing target +
  absence of new target.
- Post-sanity asserts schad=7, buch=7, end=7, anord=7, cost=2 — hard
  RAISE EXCEPTION on any drift.
- DOWN strips both new targets via array_remove with the same WHERE.
- No deadline_rules.updated_at writes; column exists but the migration
  is single-purpose and leaves it as-is.

Dry-run via Supabase MCP confirmed:
- UP yields {schad:7, buch:7, end:7, anord:7, cost:2} on prod.
- DOWN restores {schad:0, buch:0, end:7, anord:7, cost:2}.
- DB returned to pre-state; the real golang-migrate boot path will
  apply 138 cleanly at next deploy.

Version bump 137→138: cronus's mig 137 (proceeding_role_labels, #132)
merged to main while this branch was in flight. Rebased onto current
main, renamed files, rewrote all "mig 137" references inside the SQL +
test code.

Test:
- lookup_events_test.go: the schadensbemessung empty-result assertion
  becomes the inverse (rules expected). Adds a parallel bucheinsicht
  assertion. Same anchor-row shape check as the existing endentscheidung
  case (DepthFromAnchor=1, target ∈ AppliesToTarget, proceeding_type
  = upc.apl.unified).
- `go test ./...` green post-rebase, including pkg/litigationplanner/
  appeal_target_label_test.go added by cronus's mig 137.

Refs: m/paliad#134, t-paliad-303.
Lessons applied from mig 134 hotfixes: audit_reason set_config, no
updated_at writes, audit live DB before drafting, RAISE EXCEPTION on
integrity violations.
2026-05-26 15:43:36 +02:00
mAi
46b58dcf41 Merge: t-paliad-301 — Berufung tile UX: collapse side selectors + appeal-target trigger labels (mig 137) (m/paliad#132)
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
2026-05-26 15:37:51 +02:00
mAi
9da4715137 feat(litigationplanner): Berufung tile UX — collapse side selectors + appeal-target trigger label (t-paliad-301, m/paliad#132)
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
Two bugs from the Slice B1 Berufung rollout, one fix surface:

Bug A — duplicate side selectors collapse into ONE proactive-side
picker with per-proceeding role labels. The Verfahrensablauf used to
show both ?side= (Klägerseite/Beklagtenseite) AND ?appellant= (same
labels in case-form) on the Berufung tile. Now: one side picker, with
labels that swap to Berufungskläger/Berufungsbeklagter on the unified
upc.apl.unified tile (and Antragsteller/Antragsgegner Nichtigkeit on
upc.rev.cfi, Einsprechende(r)/Patentinhaber(in) on epa.opp.*).

Bug B — 'Auslösendes Ereignis' label derives from appeal_target on
the unified Berufung tile (5 target-specific strings) instead of the
proceeding's own trigger_event_label. Endentscheidung (R.118) /
Kostenentscheidung / Anordnung / Entscheidung im
Schadensbemessungsverfahren / Anordnung der Bucheinsicht.

Migration 137 (additive, no triggers on proceeding_types — verified
via mcp__supabase__execute_sql before drafting; no updated_at on the
table — lesson from mig 134 HOTFIX 3; no audit_reason setup needed):
  - ADD COLUMN role_proactive_label_de  (text NULL)
  - ADD COLUMN role_proactive_label_en  (text NULL)
  - ADD COLUMN role_reactive_label_de   (text NULL)
  - ADD COLUMN role_reactive_label_en   (text NULL)
  - Audit-first DO block lists the rows the UPDATE will touch.
  - Backfill 4 proceedings (upc.apl.unified + upc.rev.cfi +
    epa.opp.opd + epa.opp.boa); every other proceeding stays NULL
    and the renderer falls back to default labels.
  - Down drops the 4 columns.

Package additions (pkg/litigationplanner):
  - ProceedingType gains 4 *string fields (RoleProactive/Reactive
    LabelDE/EN) — db tags match the new columns; existing scans pick
    them up via the proceedingTypeColumns extension.
  - TriggerEventLabelForAppealTarget(target, lang) — Go-side map of
    the 5 appeal-target slugs to their DE/EN trigger-event labels.
    Empty result on unknown target signals "fall back to proceeding's
    own trigger_event_label".
  - Engine override: when CalcOptions.AppealTarget is set, the
    resulting Timeline.TriggerEventLabel/EN are replaced from the
    per-target map.

Frontend:
  - Removed #appellant-row div (was a separate 3-radio selector
    duplicating side).
  - Dropped ?appellant= URL state + the change handler + the init
    readback. The engine still consumes "appellant" — sourced from
    currentSide for role-swap proceedings; null otherwise.
  - applyRoleLabels(proceedingType) swaps the side-row radio labels
    from a hardcoded ROLE_LABELS map mirroring mig 137's backfill.
    Falls back to deadlines.side.claimant/defendant i18n keys for
    proceedings without overrides.
  - syncTriggerEventLabel reads data.triggerEventLabel from the calc
    response — which the engine override now sets per appeal_target,
    so no client-side mapping needed.
  - i18n cleanup: removed orphan deadlines.appellant.* keys (label /
    claimant / defendant / none) in both DE + EN.

Tests:
  - pkg/litigationplanner/appeal_target_label_test.go pins the 5×2
    label matrix + a coverage test that fails if a new entry in
    AppealTargets is added without populating the label switch.

Acceptance:
  - go build + go test all green (incl. new lp test).
  - bun run build clean (i18n codegen drops 4 keys, regenerates).
  - Live-DB audit before drafting confirmed: 4 target columns don't
    exist on proceeding_types, zero triggers on the table, exact
    column inventory matches the design.
2026-05-26 15:37:10 +02:00
mAi
16ec8c490a Merge: t-paliad-273 — Slice B.1: additive procedural_events / sequencing_rules / legal_sources (mig 136) (m/paliad#93)
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
2026-05-26 15:22:23 +02:00
mAi
f49c804ddd Merge: HOTFIX 3 — mig 134 remove non-existent updated_at column reference (t-paliad-292)
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
2026-05-26 15:19:58 +02:00
mAi
5901d40b79 fix(mig 134): remove non-existent updated_at column reference (HOTFIX 3)
paliad.proceeding_types has no updated_at column. Removing the
UPDATE ... SET ..., updated_at = now() clause from both up and down
migrations. Third bug in cronus's Slice B1 mig 134 — production
still down.

Verified columns on paliad.proceeding_types via prod-snapshot.sql:
id, code, name, description, jurisdiction, category, default_color,
sort_order, is_active, name_en, display_order, trigger_event_label_de,
trigger_event_label_en, appeal_target (added by this mig).

Refs t-paliad-292, m/paliad#124. No new issue filed — single-line
emergency fix during head's incident response.
2026-05-26 15:19:54 +02:00
mAi
c767b61a8a Merge: t-paliad-300 — HOTFIX 2: mig 134 set_config('paliad.audit_reason') (m/paliad#131)
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
2026-05-26 15:15:40 +02:00
mAi
4f94697377 fix(litigationplanner): mig 134 set_config('paliad.audit_reason') (HOTFIX 2, t-paliad-300, m/paliad#131)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Mig 134's step 4 UPDATEs paliad.deadline_rules to reassign 16 rules
to the unified upc.apl.unified proceeding_type. The mig-079 audit
trigger requires set_config('paliad.audit_reason', …, true) before
any mutation — mig 134 missed it, causing the migration runner to
abort with P0001 "audit reason required for UPDATE" on every boot
after #130 landed.

Adds the canonical set_config call at the top of both up + down,
matching the pattern from mig 082, 099, 100, 103, 106, 110, 127, 129.
2026-05-26 15:15:01 +02:00
mAi
2a56b7817c Merge: t-paliad-292 — Slice C: embedded UPC snapshot + generator (m/paliad#124 §19)
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
2026-05-26 15:13:45 +02:00
mAi
75833082fc feat(db): mig 136 — additive procedural_events / sequencing_rules / legal_sources tables (Slice B.1, t-paliad-273 / m/paliad#93)
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
Creates the three new tables that split today's paliad.deadline_rules
into its three latent concepts, plus two nullable link columns on
paliad.deadlines for B.2 dual-write.

ADDITIVE ONLY. paliad.deadline_rules is untouched. deadlines.rule_id
stays in place — it remains the authoritative deadline → rule link
until B.3 cutover flips reads and B.4 drops the legacy table.

* paliad.legal_sources        — distinct citations (87 rows backfilled).
                                pretty_de/pretty_en deferred (Go
                                legalSourcePretty still computes them
                                on read; future slice backfills).
* paliad.procedural_events    — 153 rows from distinct submission_codes
                                + 78 synthetic-code rows for the
                                NULL-submission_code branch (m's pick
                                via paliadin 2026-05-26: mint
                                'null.<8hex>' codes so every rule row
                                has a procedural event, preserving the
                                NOT NULL FK on sequencing_rules).
* paliad.sequencing_rules     — 1:1 with deadline_rules (231 rows). id
                                inherited from deadline_rules.id so any
                                existing deadlines.rule_id FK resolves
                                transitively to the new sequencing_rule
                                during the dual-write window.
* paliad.deadlines.procedural_event_id, sequencing_rule_id (nullable,
                                backfilled by JOIN on the inherited id).

Audit-first pattern (mirrors mig 135): PRE pass counts what we're about
to backfill + refuses to run if multi-row submission_codes have crept
back in (B.0 found zero; the assertion guards against a future
re-archival or rule-editor bug). POST pass asserts the four
invariants — procedural_events count, sequencing_rules 1:1,
legal_sources distinct-citation match, FK integrity — and RAISE
EXCEPTIONs on any mismatch so the transaction rolls back cleanly.

Design deviations from §4.1 (documented in the migration header):
- procedural_events.event_kind is NULLABLE. 89 live rules have NULL
  event_type today (structural / parent-only rows in the proceeding
  tree). Tightening to NOT NULL with 'other' fallback would lose
  semantics; a later slice can do it after reclassification.
- legal_sources.pretty_de / pretty_en are NULLABLE. Materialising them
  requires the Go-side legalSourcePretty(); deferred to a Go-driven
  slice. Read path keeps computing them from the citation in the
  meantime.
- submission_drafts is NOT modified (instruction scope is explicit:
  tables + deadlines columns only).

Down migration: drops the two deadlines columns first, then
sequencing_rules → procedural_events → legal_sources in FK-safe
order. No data loss possible (deadline_rules is the source of truth
through B.3).

Test: internal/db/migration_136_test.go restates the four
invariants in Go so they survive PL/pgSQL refactors. Skipped without
TEST_DATABASE_URL.

Verified on live (read-only): 153 distinct codes + 78 distinct
synthetic-code candidates = 231 = deadline_rules row count. 87
distinct legal_sources. Zero 8-hex synthetic-code collisions in the
live UUIDs.

Hard-stop: B.2 dual-write requires explicit m greenlight before
RuleEditorService starts writing to the new tables. B.4 destructive
drop additionally requires m's downtime window + a
paliad.deadline_rules_pre_<N> snapshot in the same migration.
2026-05-26 15:12:12 +02:00
mAi
ce28ea972e feat(litigationplanner): embedded UPC snapshot + generator (Slice C, m/paliad#124 §19)
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
Lays the foundation for youpc.org's cross-repo integration: an
in-package UPC subset of paliad's deadline corpus, embedded as JSON,
that any consumer can use to run the litigationplanner engine without
DB access.

Generator (cmd/gen-upc-snapshot):
  - Reads paliad's live DB (DATABASE_URL), applies pending migrations
    to match schema HEAD, SELECTs the UPC subset
    (proceeding_types WHERE jurisdiction='UPC' AND is_active=true,
    deadline_rules WHERE lifecycle_state='published' AND is_active=true
    on those proceedings, referenced trigger_events, DE+UPC holidays,
    UPC courts).
  - Writes pretty-printed JSON to
    pkg/litigationplanner/embedded/upc/{proceeding_types, rules,
    trigger_events, holidays, courts, meta}.json.
  - Idempotent — same DB state → same output (modulo
    meta.generated_at + auto-versioned suffix).
  - Date-stamped versioning (YYYY-MM-DD-N) with same-day suffix bump.
  - Operator runbook in cmd/gen-upc-snapshot/README.md.

Embedded subpackage (pkg/litigationplanner/embedded/upc/):
  - embed.go    — //go:embed *.json + LoadMeta()
  - snapshot.go — SnapshotCatalog (full lp.Catalog impl: LoadProceeding
    / LoadProceedingByID / LoadRuleByID / LoadRuleByCode /
    LoadRulesByTriggerEvent / LoadTriggerEventsByIDs / LookupEvents);
    O(1) map lookups; LookupEvents linear over the < 100-row UPC corpus.
  - holidays.go — SnapshotHolidayCalendar implementing lp.HolidayCalendar
    (IsNonWorkingDay / Adjust* with structured AdjustmentReason).
  - courts.go   — SnapshotCourtRegistry implementing lp.CourtRegistry.
  - Compile-time assertions (_ lp.X = (*Snapshot*)(nil)) catch
    interface drift.

Wire-up for consumers:
  cat, _ := upc.NewCatalog()
  hc, _  := upc.NewHolidayCalendar()
  cr, _  := upc.NewCourtRegistry()
  timeline, _ := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
                              lp.CalcOptions{}, cat, hc, cr)

Tests (snapshot_test.go, all DB-free):
  - meta parses cleanly, non-zero counts
  - LoadProceeding(upc.inf.cfi) returns expected proc + rules
  - LoadProceeding(unknown) returns ErrUnknownProceedingType
  - LookupEvents(Jurisdiction:UPC, all-following) covers corpus
  - LookupEvents(party=defendant, next) scopes anchors correctly
  - engine end-to-end via lp.Calculate against the embedded snapshot
  - holiday calendar (weekends, DE closures, UPC vacation block)
  - court registry (empty courtID fallback, known + unknown court)

Placeholder data shipped (2 proceedings, 2 rules, 5 holidays, 2
courts) so tests run without a live DB. Operator regenerates against
prod via `make snapshot-upc` once migrations 134 (B1) and 135 (B3)
have landed on prod — see cmd/gen-upc-snapshot/README.md for the
runbook. The placeholder's meta.version is suffixed `-placeholder`
to make the regeneration delta obvious.

Makefile target:
  make snapshot-upc — wraps the generator + reruns the snapshot tests

Design (§19 of docs/design-litigation-planner-2026-05-26.md):
  - Embedding format: go:embed JSON (diff-friendly, no compile coupling)
  - Generator entry: cmd/gen-upc-snapshot/main.go (idiomatic Go cmd path)
  - Versioning: meta.json carries semver + generated_at + paliad_commit
  - Regeneration: manual via Make target or `go generate`; no CI cron in v1
  - Out of scope: snapshot signing, DE/EPA/DPMA snapshots, snapshot
    diff tooling

Acceptance:
  - go build clean, go test all green (incl. 6 new tests in
    pkg/litigationplanner/embedded/upc, all DB-free)
  - SnapshotCatalog passes the compile-time lp.Catalog assertion
  - Generator binary builds + runs (Idempotence verified by re-running
    against the same source data)
2026-05-26 15:11:07 +02:00
mAi
6f8b4eabb1 Merge: t-paliad-299 — HOTFIX: rename upc.apl → upc.apl.unified (unblock mig 134, restore paliad.de) (m/paliad#130)
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
2026-05-26 15:09:46 +02:00
mAi
e2d75c391d fix(litigationplanner): rename upc.apl → upc.apl.unified (HOTFIX, t-paliad-299, m/paliad#130)
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
mig 134 was inserting code='upc.apl' (2 segments) into paliad.proceeding_types,
which carries paliad_proceeding_code_shape CHECK requiring 3 dot-segments OR
'^_archived_'. Every container restart hit the constraint, rolled the migration
TXN back, and crash-looped paliad.de.

Rename the unified Berufung code to 'upc.apl.unified' (3 segments, satisfies the
constraint, preserves design intent). The pre-existing constraint is a useful
jurisdiction.category.specific invariant — keep it, fix the new row.

Touched only string literals:
- mig 134 up.sql + down.sql (insert, lookups, post-checks)
- frontend/src/verfahrensablauf.tsx (UPC_TYPES code + i18nKey)
- frontend/src/client/verfahrensablauf.ts (APPELLANT_AXIS + APPEAL_TARGET sets)
- frontend/src/client/i18n.ts (DE + EN translation rows)
- frontend/src/i18n-keys.ts (auto-regen via bun build)
- internal/services/lookup_events_test.go (anchor-row assertion)

Verified: `grep -rn "'upc\.apl'\|\"upc\.apl\""` returns zero hits.
go build, bun run build, go test ./... all green.
2026-05-26 15:09:12 +02:00
mAi
932b177779 Merge: t-paliad-292 — Slice B3: primary_party CHECK constraint + IsValidPrimaryParty helper (mig 135 audit-first) (m/paliad#124 §18.3)
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
2026-05-26 13:59:06 +02:00
mAi
989941c648 feat(litigationplanner): primary_party CHECK constraint + IsValidPrimaryParty helper (Slice B3, m/paliad#124 §18.3)
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
Tightens paliad.deadline_rules.primary_party from free-text to a CHECK
constraint over the canonical four-value vocab (claimant / defendant /
court / both). NULL stays valid for the 78 cross-cutting orphan
concept seeds (Wiedereinsetzung, Versäumnisurteil-Einspruch,
Schriftsatznachreichung, Weiterbehandlung) — they have no
proceeding_type_id binding so they're outside the calculator's path;
loosening the CHECK to "IS NULL OR IN (…)" keeps them valid without
backfill gymnastics.

Migration 135 (audit-first):
  - DO block RAISEs NOTICE for every non-conforming row + RAISEs
    EXCEPTION if any dirty rows exist (manual cleanup required).
    Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
    on the current corpus; the audit pass stays in the migration as
    safety against future drift.
  - ALTER TABLE … ADD CONSTRAINT deadline_rules_primary_party_chk
    CHECK (primary_party IS NULL OR primary_party IN
           ('claimant', 'defendant', 'court', 'both'))
  - Post-migration distribution NOTICE so the operator sees the
    final per-value count.
  - Down = DROP CONSTRAINT. No data revert needed.

Package additions (pkg/litigationplanner):
  - PrimaryParty* constants (PrimaryPartyClaimant / Defendant / Court
    / Both) + PrimaryParties[] ordered list + IsValidPrimaryParty(s)
    predicate. Empty string is "no value supplied" = valid (NULL maps
    to empty on the wire); non-empty must match one of the four
    canonical values.
  - Sibling unit tests (primary_party_test.go) pin the four-value
    vocab + the chip order + IsValidAppealTarget's matching shape.

Rule-editor validation hook (rule_editor_service.go):
  - Create() validates input.PrimaryParty before INSERT.
  - UpdateDraft() validates patch.PrimaryParty before UPDATE.
  - Both surface a user-friendly 400 with the canonical vocab listed
    instead of leaking the raw PG CHECK constraint-violation message.
  - Uses errors.Is(err, ErrInvalidInput) so handler 400 routing
    continues to work.

services/fristenrechner.go cleanup:
  - The B2-inlined isValidPartyForLookup helper is replaced with the
    canonical lp.IsValidPrimaryParty. No behaviour change.

No frontend changes — the rule-editor's primary_party UI already
constrains to the four values via a select; the validation hook is
defense-in-depth.

Audit:
  - go build + go test (incl. new lp unit tests) all green
  - Pre-migration audit confirmed: 26 claimant + 26 defendant + 38
    court + 63 both + 78 NULL = 231 total, all in canonical vocab
  - event_categories.party (text[] array, narrower semantic) is
    NOT touched in this migration per the design doc's
    "out of scope, separate follow-up" decision
2026-05-26 13:58:33 +02:00
mAi
db8e8ba6fd Merge: t-paliad-292 — Slice B2: multi-axis catalog query API (LookupEvents, 5-axis AND filter, depth toggle) (m/paliad#124 §18.2)
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
2026-05-26 13:55:24 +02:00
38 changed files with 3230 additions and 132 deletions

View File

@@ -21,7 +21,7 @@
# the test runner's working dirs. None of them touch internal/db/migrations/
# files.
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot snapshot-upc
help:
@echo "Paliad — developer targets"
@@ -33,6 +33,8 @@ help:
@echo " test Short test pass — covers gate tier"
@echo " test-go Full Go suite with race detector"
@echo " test-frontend Frontend bun:test suite"
@echo " snapshot-upc Regenerate pkg/litigationplanner/embedded/upc/ from live DB"
@echo " (needs DATABASE_URL — see cmd/gen-upc-snapshot/README.md)"
@echo ""
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
@@ -141,3 +143,22 @@ refresh-snapshot:
' internal/db/testdata/prod-snapshot.sql.tmp > internal/db/testdata/prod-snapshot.sql
@rm internal/db/testdata/prod-snapshot.sql.tmp
@wc -l internal/db/testdata/prod-snapshot.sql
# Regenerate the embedded UPC snapshot from a live paliad DB. The
# generator applies pending migrations first, then SELECTs the UPC
# subset and writes JSON files under pkg/litigationplanner/embedded/upc/.
#
# Requires DATABASE_URL — Slice C of the litigation-planner extraction
# (m/paliad#124 §19). See cmd/gen-upc-snapshot/README.md for the full
# operator runbook.
snapshot-upc:
@if [ -z "$$DATABASE_URL" ]; then \
echo "ERROR: DATABASE_URL is not set."; \
echo " Snapshot generation needs read access to a paliad DB."; \
echo " Set DATABASE_URL to the live paliad Postgres, then re-run."; \
exit 2; \
fi
@echo "==> regenerating UPC snapshot from $$DATABASE_URL"
go run ./cmd/gen-upc-snapshot
@echo "==> running snapshot tests against the regenerated data"
go test ./pkg/litigationplanner/embedded/upc/...

View File

@@ -0,0 +1,59 @@
# gen-upc-snapshot
Regenerates the embedded UPC snapshot consumed by
`pkg/litigationplanner/embedded/upc`. Slice C of the litigation-planner
extraction (m/paliad#124 §19). See
`docs/design-litigation-planner-2026-05-26.md` §19 for the full design.
## When to regenerate
After any change that affects the public UPC rule corpus:
- new rules merged via the admin rule-editor
- a deadline-rule migration that touches UPC rows
- a `paliad.holidays` update (new public holidays / vacation runs)
- a `paliad.courts` update (new UPC LD opens, etc.)
- a `paliad.proceeding_types` change for `jurisdiction = 'UPC'`
The snapshot is operator-controlled — there is no CI regeneration in v1.
## How to regenerate
```sh
make snapshot-upc
```
or directly:
```sh
DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot
```
Flags:
| Flag | Default | Purpose |
|-----------------|----------------------------------------|---------|
| `-output` | `./pkg/litigationplanner/embedded/upc` | directory to write JSON files into |
| `-version` | auto-derived (`YYYY-MM-DD-N`) | override the snapshot version |
| `-source-label` | empty | text label written to `meta.json` (`paliad-prod`, `paliad-dev`, …) |
The generator:
1. Applies pending migrations against `DATABASE_URL` (snapshot always matches schema HEAD).
2. SELECTs UPC active proceeding_types + their published+active rules + referenced trigger_events + DE/UPC holidays + UPC courts.
3. Writes pretty-printed JSON to `<output>/{proceeding_types,rules,trigger_events,holidays,courts,meta}.json`.
## Idempotence
Running twice with the same DB state produces the same JSON (modulo `meta.generated_at`). Diff-friendly in git.
## Versioning
`meta.json.version` uses `YYYY-MM-DD-N` where N starts at 1 and increments on same-day regenerations. The generator reads the existing `meta.json` and bumps automatically.
## After regeneration
1. Review the diff: `git diff pkg/litigationplanner/embedded/upc/`.
2. Run tests: `go test ./pkg/litigationplanner/embedded/upc/...`.
3. Commit with a message like `chore(snapshot): regenerate UPC snapshot (<reason>)`.
4. Notify any downstream consumer (youpc.org) that a new paliad release is available.

View File

@@ -0,0 +1,301 @@
// Command gen-upc-snapshot reads paliad's live deadline corpus and
// writes the UPC subset as JSON files under
// pkg/litigationplanner/embedded/upc/. The package's embedded
// catalog/holiday/court implementations then serve this data without
// any DB roundtrip — letting youpc.org (or any future consumer) run
// the litigationplanner engine against the canonical UPC rule set.
//
// Slice C (m/paliad#124 §19). See docs/design-litigation-planner-2026-05-26.md
// §19 for the full design.
//
// Usage:
//
// DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot \
// [-output ./pkg/litigationplanner/embedded/upc] \
// [-version 2026-05-26-1] \
// [-source-label paliad-dev-supabase]
//
// The generator applies migrations against DATABASE_URL before
// SELECTing (so the snapshot always matches schema HEAD). Idempotent —
// running twice with the same DB state produces the same JSON.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
const (
defaultOutput = "./pkg/litigationplanner/embedded/upc"
defaultSourceLabel = ""
)
// Meta is the version block written to meta.json. The embedded sub-
// package re-defines this type so consumers can decode it without
// importing the cmd; the cmd holds the canonical write shape.
type Meta struct {
Version string `json:"version"`
GeneratedAt time.Time `json:"generated_at"`
PaliadCommit string `json:"paliad_commit,omitempty"`
SourceDBLabel string `json:"source_db_label,omitempty"`
RuleCount int `json:"rule_count"`
ProceedingCount int `json:"proceeding_count"`
TriggerEventCount int `json:"trigger_event_count"`
HolidayCount int `json:"holiday_count"`
CourtCount int `json:"court_count"`
}
// EmbeddedHoliday is the holiday row shape the embedded snapshot
// stores. JSON tags mirror paliad.holidays so the generator's SELECT
// scans onto it directly + the embedded HolidayCalendar reads the
// same tag.
type EmbeddedHoliday struct {
Date string `db:"date_iso" json:"date"`
Name string `db:"name" json:"name"`
Country *string `db:"country" json:"country,omitempty"`
Regime *string `db:"regime" json:"regime,omitempty"`
State *string `db:"state" json:"state,omitempty"`
HolidayType string `db:"holiday_type" json:"holiday_type"`
}
// EmbeddedCourt is the court row shape the embedded snapshot stores.
type EmbeddedCourt struct {
ID string `db:"id" json:"id"`
Code string `db:"code" json:"code"`
NameDE string `db:"name_de" json:"name_de"`
NameEN string `db:"name_en" json:"name_en"`
Country string `db:"country" json:"country"`
Regime *string `db:"regime" json:"regime,omitempty"`
CourtType string `db:"court_type" json:"court_type"`
ParentID *string `db:"parent_id" json:"parent_id,omitempty"`
SortOrder int `db:"sort_order" json:"sort_order"`
}
func main() {
output := flag.String("output", defaultOutput, "directory to write JSON files into")
version := flag.String("version", "", "explicit snapshot version (auto-derived if empty)")
sourceLabel := flag.String("source-label", defaultSourceLabel, "label for source_db in meta.json")
flag.Parse()
url := os.Getenv("DATABASE_URL")
if url == "" {
log.Fatal("DATABASE_URL must be set")
}
if err := db.ApplyMigrations(url); err != nil {
log.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
log.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
if err := run(ctx, pool, *output, *version, *sourceLabel); err != nil {
log.Fatalf("snapshot: %v", err)
}
}
func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string) error {
if err := os.MkdirAll(output, 0o755); err != nil {
return fmt.Errorf("mkdir output: %w", err)
}
// 1. Proceeding types — UPC + active only. The unified upc.apl row
// from B1 mig 134 is included; the 3 archived old appeal codes
// (is_active=false) are filtered out by the WHERE.
var procs []litigationplanner.ProceedingType
if err := pool.SelectContext(ctx, &procs, `
SELECT id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active,
trigger_event_label_de, trigger_event_label_en,
appeal_target
FROM paliad.proceeding_types
WHERE jurisdiction = 'UPC' AND is_active = true
ORDER BY sort_order, id`); err != nil {
return fmt.Errorf("select proceeding_types: %w", err)
}
if len(procs) == 0 {
return fmt.Errorf("no active UPC proceeding_types — refusing to write empty snapshot")
}
procIDs := make([]int, 0, len(procs))
for _, p := range procs {
procIDs = append(procIDs, p.ID)
}
// 2. Deadline rules — published + active rules for those proceedings.
const ruleCols = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type, duration_value,
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
alt_duration_value, alt_duration_unit, alt_rule_code,
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,
choices_offered, applies_to_target`
q, args, err := sqlx.In(`
SELECT `+ruleCols+`
FROM paliad.deadline_rules
WHERE proceeding_type_id IN (?)
AND is_active = true
AND lifecycle_state = 'published'
ORDER BY proceeding_type_id, sequence_order`, procIDs)
if err != nil {
return fmt.Errorf("build rules IN: %w", err)
}
q = pool.Rebind(q)
var rules []litigationplanner.Rule
if err := pool.SelectContext(ctx, &rules, q, args...); err != nil {
return fmt.Errorf("select rules: %w", err)
}
// 3. Trigger events referenced by any UPC rule's trigger_event_id.
triggerIDSet := make(map[int64]struct{})
for _, r := range rules {
if r.TriggerEventID != nil {
triggerIDSet[*r.TriggerEventID] = struct{}{}
}
}
var triggers []litigationplanner.TriggerEvent
if len(triggerIDSet) > 0 {
triggerIDs := make([]int64, 0, len(triggerIDSet))
for id := range triggerIDSet {
triggerIDs = append(triggerIDs, id)
}
q, args, err := sqlx.In(`
SELECT id, code, name, name_de, description, is_active, created_at
FROM paliad.trigger_events
WHERE id IN (?)
ORDER BY id`, triggerIDs)
if err != nil {
return fmt.Errorf("build triggers IN: %w", err)
}
q = pool.Rebind(q)
if err := pool.SelectContext(ctx, &triggers, q, args...); err != nil {
return fmt.Errorf("select trigger_events: %w", err)
}
}
// 4. Holidays — DE national + UPC regime entries. The embedded
// calendar serves UPC computations so both axes matter.
var holidays []EmbeddedHoliday
if err := pool.SelectContext(ctx, &holidays, `
SELECT to_char(date, 'YYYY-MM-DD') AS date_iso,
name, country, regime, state, holiday_type
FROM paliad.holidays
WHERE country = 'DE' OR regime = 'UPC'
ORDER BY date, name`); err != nil {
return fmt.Errorf("select holidays: %w", err)
}
// 5. Courts — UPC subset.
var courts []EmbeddedCourt
if err := pool.SelectContext(ctx, &courts, `
SELECT id, code, name_de, name_en, country, regime, court_type, parent_id, sort_order
FROM paliad.courts
WHERE is_active = true
AND (regime = 'UPC' OR court_type LIKE 'upc%')
ORDER BY sort_order, id`); err != nil {
return fmt.Errorf("select courts: %w", err)
}
// 6. Compose meta.
meta := Meta{
Version: resolveVersion(version, output),
GeneratedAt: time.Now().UTC().Truncate(time.Second),
PaliadCommit: gitCommitShort(),
SourceDBLabel: sourceLabel,
RuleCount: len(rules),
ProceedingCount: len(procs),
TriggerEventCount: len(triggers),
HolidayCount: len(holidays),
CourtCount: len(courts),
}
// 7. Write each file.
files := []struct {
name string
data any
}{
{"proceeding_types.json", procs},
{"rules.json", rules},
{"trigger_events.json", triggers},
{"holidays.json", holidays},
{"courts.json", courts},
{"meta.json", meta},
}
for _, f := range files {
path := filepath.Join(output, f.name)
buf, err := json.MarshalIndent(f.data, "", " ")
if err != nil {
return fmt.Errorf("marshal %s: %w", f.name, err)
}
buf = append(buf, '\n')
if err := os.WriteFile(path, buf, 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
}
log.Printf("snapshot written: version=%s rules=%d proceedings=%d triggers=%d holidays=%d courts=%d → %s",
meta.Version, meta.RuleCount, meta.ProceedingCount,
meta.TriggerEventCount, meta.HolidayCount, meta.CourtCount, output)
return nil
}
// resolveVersion picks a date-stamped version slug, bumping the suffix
// past any pre-existing same-day version found in the existing
// meta.json. If the caller passed -version, that wins.
func resolveVersion(explicit, output string) string {
if explicit != "" {
return explicit
}
today := time.Now().UTC().Format("2006-01-02")
// Read prior meta to detect same-day collisions.
prior, err := os.ReadFile(filepath.Join(output, "meta.json"))
if err != nil {
return today + "-1"
}
var pm Meta
if err := json.Unmarshal(prior, &pm); err != nil {
return today + "-1"
}
if !strings.HasPrefix(pm.Version, today+"-") {
return today + "-1"
}
// Same day: bump the suffix.
suffix := pm.Version[len(today)+1:]
var n int
if _, err := fmt.Sscanf(suffix, "%d", &n); err != nil {
return today + "-1"
}
return fmt.Sprintf("%s-%d", today, n+1)
}
// gitCommitShort returns the short SHA of the paliad checkout. Best-
// effort — empty string when we're not in a git checkout.
func gitCommitShort() string {
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

View File

@@ -1449,4 +1449,170 @@ No `AskUserQuestion` per inventor protocol; head escalates to m if material.
---
## §19 Slice C — embedded UPC snapshot + generator (2026-05-26)
Slice A landed the package, Slice B added the catalog API surface. Slice C lays the foundation for the youpc.org cross-repo integration: an in-package UPC subset of paliad's deadline corpus, embedded as JSON, that youpc.org can use to run the engine without any paliad DB access.
### §19.1 Goals
1. **Zero DB dependency for snapshot consumers.** youpc.org imports `pkg/litigationplanner/embedded/upc` and gets a working Catalog / HolidayCalendar / CourtRegistry without ever touching paliad's Postgres.
2. **Reproducible regeneration.** A generator binary (`cmd/gen-upc-snapshot`) reads paliad's live DB and produces the JSON. Idempotent — same DB state in, same JSON out.
3. **Versioned snapshots.** Each snapshot carries a `version` + `generated_at` so consumers can detect regeneration and decide whether to bump their go.mod.
4. **Stays in lockstep with paliad's engine.** The embedded data conforms to the same `Rule` / `ProceedingType` Go types the engine consumes — no schema drift, no parallel-vocab risk.
### §19.2 Embedding format
**Pick: `//go:embed` of JSON.**
Three candidates considered:
- A. **`//go:embed` of JSON files** — generator emits human-readable JSON; package reads at boot via `embed.FS`. Diff-friendly in git; youpc.org sees the bytes change in code review.
- B. **Generated Go const literals** — generator emits a `.go` file with the rule slice inlined. Type-safe at compile; harder to diff (big generated files); pollutes `git log -p` with mechanical changes.
- C. **External resource fetched at runtime** — youpc.org would HTTP-GET the snapshot from a paliad endpoint. Adds runtime coupling between the two services; defeats the "zero DB dependency" goal.
**(R) = A**. JSON is the wire shape paliad's API already serves; the package's `Rule` struct already has compatible `json:` tags from Slice A. The generated bytes survive `git diff` cleanly. youpc.org can also vendor the JSON via go-module if they want fully reproducible builds.
### §19.3 File layout
```
pkg/litigationplanner/embedded/upc/
embed.go ← //go:embed *.json + package metadata
snapshot.go ← SnapshotCatalog struct + Load() helper
snapshot_test.go ← unit tests against the embedded data
rules.json ← generator output: all UPC rules
proceeding_types.json ← generator output: all UPC proceeding types
trigger_events.json ← generator output: UPC-referenced trigger events
holidays.json ← generator output: DE + UPC regime holidays
courts.json ← generator output: UPC courts
meta.json ← generator output: {version, generated_at, paliad_commit, source_db_label}
cmd/gen-upc-snapshot/
main.go ← generator entry point
README.md ← operator runbook
```
`pkg/litigationplanner/embedded/upc` is the public consumer surface. youpc.org imports it as:
```go
import upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"
cat, _ := upc.NewCatalog()
hc, _ := upc.NewHolidayCalendar()
cr, _ := upc.NewCourtRegistry()
timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26", lp.CalcOptions{...}, cat, hc, cr)
```
### §19.4 Snapshot data shape
The five data files (`rules.json`, `proceeding_types.json`, `trigger_events.json`, `holidays.json`, `courts.json`) are each a top-level JSON array of the corresponding type. The package's `Rule` / `ProceedingType` / `TriggerEvent` structs deserialise directly (their `json:` tags align with paliad's wire shape).
`holidays.json` and `courts.json` use minimal structures defined in the embedded sub-package (the package's core API only requires `HolidayCalendar` / `CourtRegistry` interfaces — no struct contract).
`meta.json` carries the versioning block:
```json
{
"version": "2026-05-26-1",
"generated_at": "2026-05-26T15:01:00Z",
"paliad_commit": "932b177",
"source_db_label": "paliad-dev-supabase",
"rule_count": 81,
"proceeding_count": 9,
"trigger_event_count": 2,
"holiday_count": 142,
"court_count": 18
}
```
`version` uses a date-stamped scheme (`YYYY-MM-DD-N` where N starts at 1 and increments for same-day regenerations) — simple, sortable, no merge conflicts on regen.
### §19.5 Generator
`cmd/gen-upc-snapshot/main.go` runs as:
```sh
DATABASE_URL=postgres://... \
go run ./cmd/gen-upc-snapshot \
-output ./pkg/litigationplanner/embedded/upc
```
Flow:
1. Connect to `DATABASE_URL` (paliad's live DB).
2. Apply migrations first (`db.ApplyMigrations(url)`) — ensures the snapshot matches schema HEAD.
3. SELECT all `paliad.proceeding_types` WHERE `jurisdiction = 'UPC'` AND `is_active = true`. (After B1 the unified `upc.apl` is the only appeal proceeding — the 3 archived old codes are filtered out.)
4. SELECT all `paliad.deadline_rules` for those proceeding ids WHERE `lifecycle_state = 'published'` AND `is_active = true`.
5. SELECT `paliad.trigger_events` referenced by any rule's `trigger_event_id`.
6. SELECT `paliad.holidays` filtered to `country = 'DE' OR regime = 'UPC'` (the union UPC procedures need).
7. SELECT `paliad.courts` filtered to `regime = 'UPC' OR court_type LIKE 'upc%'` (UPC court hierarchy).
8. Write each result set to `<output>/<name>.json` (pretty-printed for diff-friendliness).
9. Compute meta — current paliad commit (via `git rev-parse --short HEAD`), timestamp, row counts.
10. Write `meta.json`.
**Versioning rule**: the generator never overwrites a meta.json with `version` equal to an existing one. If today's date is already used (suffix `-1`), the generator bumps to `-2`. This keeps regenerations within a day distinguishable. Operator can pass `-version <string>` to override.
### §19.6 Regeneration trigger
Manual. Three entry points:
- **`make snapshot-upc`** — Make target invokes the generator with `DATABASE_URL` from env. Documented in `cmd/gen-upc-snapshot/README.md`.
- **`go generate ./pkg/litigationplanner/embedded/upc`** — `//go:generate` directive on a stub in the package. Same effect; lets contributors discover the regen path from the package they're modifying.
- **Operator runs the command directly** — power-user path.
**No CI regeneration in v1.** The snapshot is operator-controlled. Future slice can add a nightly CI job that opens a PR with the regenerated snapshot if drift is detected (out of scope here).
### §19.7 SnapshotCatalog implementation
In `pkg/litigationplanner/embedded/upc/snapshot.go`:
```go
type SnapshotCatalog struct {
proceedings []litigationplanner.ProceedingType
rules []litigationplanner.Rule
triggerEvents map[int64]litigationplanner.TriggerEvent
rulesByProc map[int][]litigationplanner.Rule // for LoadProceeding
rulesByID map[uuid.UUID]litigationplanner.Rule
procByID map[int]litigationplanner.ProceedingType
procByCode map[string]litigationplanner.ProceedingType
}
func NewCatalog() (*SnapshotCatalog, error) // parses embedded JSON
```
All 7 Catalog interface methods (`LoadProceeding`, `LoadProceedingByID`, `LoadRuleByID`, `LoadRuleByCode`, `LoadRulesByTriggerEvent`, `LoadTriggerEventsByIDs`, `LookupEvents`) implemented against the in-memory maps. Lookup methods are O(1) on the indexed maps; `LookupEvents` does a linear scan of `rules` (the UPC subset is < 100 rows; no index needed).
`ProjectHint` is ignored on the snapshot side (youpc.org has no projects). `applies_to_target` filter for B1 works identically the rules carry the same array.
`HolidayCalendar` impl mirrors paliad's `HolidayService` but reads from the embedded holiday slice instead of paliad.holidays. Same `AdjustForNonWorkingDaysWithReason` semantics.
`CourtRegistry` impl mirrors `CourtService.CountryRegime`. UPC courts only.
### §19.8 Tests
`snapshot_test.go` exercises:
- Snapshot loads without error
- `meta.json` parses + has non-zero counts
- `LoadProceeding(ctx, "upc.inf.cfi", ProjectHint{})` returns the expected proceeding + > 0 rules
- `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all rules
- A golden compute: `Calculate(ctx, "upc.inf.cfi", "2026-01-15", CalcOptions{}, cat, hc, cr)` produces a non-empty timeline with a known root rule (Klageerhebung)
All tests run without a DB (zero `os.Getenv("TEST_DATABASE_URL")` checks).
### §19.9 Acceptance criteria
1. `cmd/gen-upc-snapshot` exists + builds + runs against the live paliad DB.
2. `pkg/litigationplanner/embedded/upc/*.json` checked in with the first generated snapshot.
3. `embedded/upc.NewCatalog()` (+ `NewHolidayCalendar` + `NewCourtRegistry`) return ready-to-use implementations of the package interfaces.
4. Unit tests in `embedded/upc` pass without `TEST_DATABASE_URL` (no DB roundtrip).
5. `make snapshot-upc` regenerates the snapshot.
6. `go build ./...` + `go test ./...` all green.
### §19.10 Out of scope (deferred to follow-up)
- Snapshot signing / integrity attestation. v1 is plain JSON; future slice can ship a `meta.sig` next to `meta.json` for tamper detection.
- DE/EPA/DPMA snapshots. v1 only ships the UPC subset (matches youpc.org's scope). Future jurisdictions add as sibling packages: `embedded/de`, `embedded/epa`, etc.
- CI regeneration cron. Operator-driven only in v1.
- Snapshot diff tooling. v1 relies on `git diff` of the JSON files.
---
*End of design doc.*

View File

@@ -237,7 +237,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.upc.disc.cfi": "Bucheinsicht",
"deadlines.upc.apl.cost": "Berufung Kosten",
"deadlines.upc.apl.order": "Berufung Anordnungen",
"deadlines.upc.apl": "Berufung",
"deadlines.upc.apl.unified": "Berufung",
"deadlines.appeal_target.label": "Worauf richtet sich die Berufung?",
"deadlines.appeal_target.endentscheidung": "Endentscheidung",
"deadlines.appeal_target.kostenentscheidung": "Kostenentscheidung",
@@ -462,10 +462,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.from_project": "Aus Akte:",
"deadlines.side.override": "Andere Seite wählen",
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Zusammengesetzt:",
"deadlines.event.unit.days.one": "Tag",
"deadlines.event.unit.days.many": "Tage",
@@ -3334,7 +3330,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.upc.dmgs.cfi": "Damages Determination",
"deadlines.upc.disc.cfi": "Lay-open Books",
"deadlines.upc.apl.cost": "Cost-Decision Appeal",
"deadlines.upc.apl": "Appeal",
"deadlines.upc.apl.unified": "Appeal",
"deadlines.appeal_target.label": "Appeal against:",
"deadlines.appeal_target.endentscheidung": "Final Decision",
"deadlines.appeal_target.kostenentscheidung": "Cost Decision",
@@ -3567,10 +3563,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.from_project": "From case:",
"deadlines.side.override": "Choose other side",
"deadlines.side.hint": "Pick a side to focus the columns.",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Composite:",
"deadlines.event.unit.days.one": "day",
"deadlines.event.unit.days.many": "days",

View File

@@ -32,18 +32,20 @@ import {
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
// view is shareable and survives reload:
// ?side=claimant|defendant swaps which column owns the user's
// side (proactive vs reactive label).
// Default null = claimant-on-the-left.
// ?appellant=claimant|defendant → collapses party=both rows into the
// appellant's column (no mirror).
// Only meaningful for role-swap
// proceedings (Appeal etc.). Default
// null = legacy mirror behaviour.
// Perspective state. URL-driven so the view is shareable + survives
// reload:
// ?side=claimant|defendant swaps which column owns the user's
// side (proactive vs reactive label).
// Default null = claimant-on-the-left.
//
// t-paliad-301 / m/paliad#132 collapsed the duplicate ?side= +
// ?appellant= selectors into the single proactive-side picker above.
// For role-swap proceedings (Appeal / EPA Opposition / DE Revision /
// DPMA Appeal) the picker's labels swap to per-proceeding role
// strings (Berufungskläger / Berufungsbeklagter, …) via ROLE_LABELS
// below — but the underlying claimant/defendant value the engine
// consumes is unchanged.
let currentSide: Side = null;
let currentAppellant: Side = null;
// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the
// page is opened with ?project=<id> and that project has our_side set,
@@ -52,19 +54,15 @@ let currentAppellant: Side = null;
// link, which clears this flag (radio cluster takes over again).
let sidePrefilledFromProject = false;
// Proceedings where one party initiates and "both" rows are role-swap
// (i.e. either party files depending on who acted at the lower
// instance). For these proceedings the appellant selector is meaningful
// — when set, "both" rows collapse to a single row in the appellant's
// column. For first-instance proceedings (Inf, Rev, …) the selector is
// hidden because there's no appellant axis.
//
// Today: every upc.apl.* family member plus dpma.appeal.* and
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
// Conservative — false negatives just hide a control; false positives
// would show an irrelevant control.
// Role-swap proceedings — the side picker doubles as the appellant
// axis. After t-paliad-301 collapsed the duplicate selectors, the
// engine reads "appellant" from the single side value for these
// proceedings (so a row with primary_party=both renders only in the
// chosen side's column). For first-instance proceedings (Inf, Rev,
// …) the side picker still narrows columns but doesn't collapse
// the "both" rows.
const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl",
"upc.apl.unified",
"de.inf.olg",
"de.inf.bgh",
"de.null.bgh",
@@ -73,12 +71,50 @@ const APPELLANT_AXIS_PROCEEDINGS = new Set([
"epa.opp.boa",
]);
// Per-proceeding role labels (t-paliad-301 / m/paliad#132 Bug A).
// Mirrors paliad.proceeding_types.role_*_label_* — the canonical
// definition lives in the DB; this map is the frontend's view of
// it. Proceedings absent from the map fall back to the generic
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
//
// Keep in sync with mig 137's backfill. Adding a row here without a
// matching DB row is fine (the DB col is NULL → still falls back to
// default; UI shows the override). Adding to the DB without here
// means the UI uses defaults — harmless but inconsistent.
type RoleLabels = { proDE: string; reDE: string; proEN: string; reEN: string };
const ROLE_LABELS: Record<string, RoleLabels> = {
"upc.apl.unified": {
proDE: "Berufungskläger",
reDE: "Berufungsbeklagter",
proEN: "Appellant",
reEN: "Appellee",
},
"upc.rev.cfi": {
proDE: "Antragsteller (Nichtigkeit)",
reDE: "Antragsgegner (Nichtigkeit)",
proEN: "Revocation claimant",
reEN: "Revocation defendant",
},
"epa.opp.opd": {
proDE: "Einsprechende(r)",
reDE: "Patentinhaber(in)",
proEN: "Opponent",
reEN: "Patentee",
},
"epa.opp.boa": {
proDE: "Einsprechende(r)",
reDE: "Patentinhaber(in)",
proEN: "Opponent",
reEN: "Patentee",
},
};
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
// Proceedings that surface the appeal-target chip group. Currently
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
// can opt in by adding the code here.
const APPEAL_TARGET_PROCEEDINGS = new Set([
"upc.apl",
"upc.apl.unified",
]);
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
@@ -105,11 +141,6 @@ function readSideFromURL(): Side {
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function readAppellantFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("appellant");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function writeSideToURL(s: Side) {
const url = new URL(window.location.href);
if (s === null) url.searchParams.delete("side");
@@ -117,11 +148,31 @@ function writeSideToURL(s: Side) {
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
function writeAppellantToURL(a: Side) {
const url = new URL(window.location.href);
if (a === null) url.searchParams.delete("appellant");
else url.searchParams.set("appellant", a);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
// t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row
// radio labels for the currently selected proceeding. Proceedings
// without an entry fall back to the existing
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
function applyRoleLabels(proceedingType: string) {
const lang = getLang() === "en" ? "en" : "de";
const claimantSpan = document.querySelector<HTMLElement>(
"input[type=radio][name=side][value=claimant] + span"
);
const defendantSpan = document.querySelector<HTMLElement>(
"input[type=radio][name=side][value=defendant] + span"
);
if (!claimantSpan || !defendantSpan) return;
const labels = ROLE_LABELS[proceedingType];
if (labels) {
claimantSpan.textContent = lang === "en" ? labels.proEN : labels.proDE;
defendantSpan.textContent = lang === "en" ? labels.reEN : labels.reDE;
} else {
// Default — let i18n drive via data-i18n attribute. Reset to the
// canonical i18n value so a previous override doesn't stick when
// switching from upc.apl.unified back to upc.inf.cfi.
claimantSpan.textContent = t("deadlines.side.claimant");
defendantSpan.textContent = t("deadlines.side.defendant");
}
}
// Slice B1 — appeal-target URL state. Empty string = no target picked
@@ -432,7 +483,12 @@ function renderResults(data: DeadlineResponse) {
editable: true,
showNotes,
side: currentSide,
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
// t-paliad-301: the appellant axis collapses into the single
// side picker. For role-swap proceedings, currentSide IS the
// appellant pick (so a row with primary_party=both renders only
// in the picked side's column). For non-role-swap proceedings,
// the appellant axis is irrelevant — pass null.
appellant: hasAppellantAxis(selectedType) ? currentSide : null,
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
@@ -501,8 +557,8 @@ function selectProceeding(btn: HTMLButtonElement) {
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
syncAppellantRowVisibility();
syncAppealTargetRowVisibility();
applyRoleLabels(selectedType);
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
@@ -510,23 +566,6 @@ function selectProceeding(btn: HTMLButtonElement) {
scheduleCalc(0);
}
// syncAppellantRowVisibility hides the appellant selector for
// proceedings that have no appellant axis (first-instance Inf, Rev,
// …). Clears the in-memory state and the URL param when hidden so a
// shared link with ?appellant= doesn't leak into an unrelated
// proceeding's render.
function syncAppellantRowVisibility() {
const row = document.getElementById("appellant-row");
if (!row) return;
const visible = hasAppellantAxis(selectedType);
row.style.display = visible ? "" : "none";
if (!visible && currentAppellant !== null) {
currentAppellant = null;
writeAppellantToURL(null);
syncRadioGroup("appellant", "");
}
}
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
// syncAppealTargetRowVisibility shows the appeal-target chip group
// when the unified upc.apl Berufung tile is selected, hides it
@@ -727,10 +766,8 @@ function initViewToggle() {
// projection of the last response, no backend involved.
function initPerspectiveControls() {
currentSide = readSideFromURL();
currentAppellant = readAppellantFromURL();
currentAppealTarget = readAppealTargetFromURL();
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? "");
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
syncSideHintVisibility();
@@ -745,16 +782,6 @@ function initPerspectiveControls() {
});
});
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
writeAppellantToURL(currentAppellant);
if (lastResponse) renderResults(lastResponse);
});
});
// Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
// Each chip change re-fetches with the new target slug so the
// timeline re-renders against the matching rule subset.

View File

@@ -1190,10 +1190,6 @@ export type I18nKey =
| "deadlines.appeal_target.kostenentscheidung"
| "deadlines.appeal_target.label"
| "deadlines.appeal_target.schadensbemessung"
| "deadlines.appellant.claimant"
| "deadlines.appellant.defendant"
| "deadlines.appellant.label"
| "deadlines.appellant.none"
| "deadlines.calculate"
| "deadlines.card.calc.add_to_project"
| "deadlines.card.calc.add_to_project.disabled"
@@ -1517,10 +1513,10 @@ export type I18nKey =
| "deadlines.trigger.label"
| "deadlines.unavailable"
| "deadlines.upc"
| "deadlines.upc.apl"
| "deadlines.upc.apl.cost"
| "deadlines.upc.apl.merits"
| "deadlines.upc.apl.order"
| "deadlines.upc.apl.unified"
| "deadlines.upc.ccr.cfi"
| "deadlines.upc.disc.cfi"
| "deadlines.upc.dmgs.cfi"

View File

@@ -39,7 +39,7 @@ const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
{ code: "upc.apl", i18nKey: "deadlines.upc.apl", name: "Berufung" },
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
];
@@ -250,23 +250,6 @@ export function renderVerfahrensablauf(): string {
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
<label className="fristen-view-option">
<input type="radio" name="appellant" value="claimant" />
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="defendant" />
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="" checked />
<span data-i18n="deadlines.appellant.none"></span>
</label>
</div>
</div>
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
Re-surfaces optional cards the user has previously
marked "Überspringen" via the per-card popover.

View File

@@ -0,0 +1,134 @@
// Slice B.1 (t-paliad-273) — migration 136 backfill invariants.
//
// The dry-run gate (migrate_test.go: TestMigrations_DryRun) catches
// migrations that crash on apply, but it rolls back inside its own
// transaction — the post-state assertions in mig 136's PL/pgSQL block
// run, but a future refactor of those assertions might forget a check
// or introduce a silent count drift. This test layers a Go-side
// invariant check on top so the contract is restated in test code,
// outside the PL/pgSQL block, against the resulting tables.
//
// Skipped without TEST_DATABASE_URL, same pattern as
// internal/services/submission_codes_shape_test.go.
package db
import (
"context"
"database/sql"
"os"
"testing"
_ "github.com/lib/pq"
)
// TestMigration136_BackfillInvariants applies every embedded migration
// (which lands mig 136 along the way) and then asserts the four
// invariants the B.1 design + B.0 findings nailed down:
//
// 1. procedural_events row count = (distinct submission_codes in
// deadline_rules) + (deadline_rules with NULL submission_code).
// Codes-bearing branch is 1:1 per the B.0 audit (no multi-row
// codes since the _archived_litigation.* removal); the NULL
// branch gets one synthetic procedural_event per rule.
// 2. sequencing_rules row count = deadline_rules row count (1:1).
// 3. legal_sources row count = distinct legal_source in
// deadline_rules (NULL excluded).
// 4. every sequencing_rules row's procedural_event_id resolves to a
// procedural_events row (NOT NULL FK already enforces this at the
// DB level — this test catches a future relaxation of the FK).
// 5. no two synthetic codes collide (covered by the UNIQUE on
// procedural_events.code; restated here for documentation).
//
// The test is robust against corpus size — it derives all expected
// counts from the live deadline_rules state, so a scratch DB with 0
// rules trivially passes, and a prod-shaped scratch DB exercises the
// real invariants.
func TestMigration136_BackfillInvariants(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping mig 136 invariant test")
}
if err := ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
ctx := context.Background()
var (
drTotal, drCodesDistinct, drCodesNull, drLegalDistinct int
peTotal, srTotal, lsTotal int
orphanPE, dupSynthetic int
)
mustQ := func(label, q string, dst *int) {
t.Helper()
if err := conn.QueryRowContext(ctx, q).Scan(dst); err != nil {
t.Fatalf("%s: %v", label, err)
}
}
mustQ("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &drTotal)
mustQ("dr_codes_distinct",
`SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL`,
&drCodesDistinct)
mustQ("dr_codes_null",
`SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL`,
&drCodesNull)
mustQ("dr_legal_distinct",
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
&drLegalDistinct)
mustQ("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &peTotal)
mustQ("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &srTotal)
mustQ("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &lsTotal)
// Invariant 1: procedural_events = distinct_codes + null_codes
wantPE := drCodesDistinct + drCodesNull
if peTotal != wantPE {
t.Errorf("procedural_events count mismatch: got %d, want %d (distinct codes=%d + null-code rules=%d)",
peTotal, wantPE, drCodesDistinct, drCodesNull)
}
// Invariant 2: sequencing_rules 1:1 with deadline_rules
if srTotal != drTotal {
t.Errorf("sequencing_rules count mismatch: got %d, want %d (1:1 with deadline_rules)",
srTotal, drTotal)
}
// Invariant 3: legal_sources = distinct legal_source
if lsTotal != drLegalDistinct {
t.Errorf("legal_sources count mismatch: got %d, want %d (distinct legal_source)",
lsTotal, drLegalDistinct)
}
// Invariant 4: every sequencing_rules.procedural_event_id resolves
mustQ("orphan_pe", `
SELECT COUNT(*)
FROM paliad.sequencing_rules sr
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE pe.id IS NULL`, &orphanPE)
if orphanPE != 0 {
t.Errorf("FK integrity violated: %d sequencing_rules row(s) have no resolving procedural_event_id", orphanPE)
}
// Invariant 5: no duplicate synthetic codes
mustQ("dup_synthetic", `
SELECT COUNT(*) FROM (
SELECT code FROM paliad.procedural_events
WHERE code LIKE 'null.%'
GROUP BY code
HAVING COUNT(*) > 1
) d`, &dupSynthetic)
if dupSynthetic != 0 {
t.Errorf("synthetic code uniqueness violated: %d duplicate(s) under 'null.%%' prefix", dupSynthetic)
}
t.Logf("mig 136 invariants OK: deadline_rules=%d, procedural_events=%d (=%d+%d), "+
"sequencing_rules=%d, legal_sources=%d (distinct legal_source=%d)",
drTotal, peTotal, drCodesDistinct, drCodesNull, srTotal, lsTotal, drLegalDistinct)
}

View File

@@ -9,13 +9,22 @@
-- never deleted them — that's what makes this down-migration safe).
-- ---------------------------------------------------------------
-- ---------------------------------------------------------------
-- 0. Audit reason (required by mig 079 trigger — step 2 UPDATEs
-- paliad.deadline_rules to reverse the reassignment).
-- ---------------------------------------------------------------
SELECT set_config(
'paliad.audit_reason',
'mig 134 DOWN: revert Slice B1 — restore 3 separate UPC appeal proceeding_types, drop applies_to_target column',
true);
-- ---------------------------------------------------------------
-- 1. Un-archive the 3 old appeal proceeding_types.
-- ---------------------------------------------------------------
UPDATE paliad.proceeding_types
SET is_active = true,
updated_at = now()
SET is_active = true
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
-- ---------------------------------------------------------------
@@ -41,10 +50,10 @@ UPDATE paliad.deadline_rules dr
WHERE dr.applies_to_target = ARRAY['anordnung']::text[];
-- ---------------------------------------------------------------
-- 3. Drop the unified upc.apl row (now orphaned).
-- 3. Drop the unified upc.apl.unified row (now orphaned).
-- ---------------------------------------------------------------
DELETE FROM paliad.proceeding_types WHERE code = 'upc.apl';
DELETE FROM paliad.proceeding_types WHERE code = 'upc.apl.unified';
-- ---------------------------------------------------------------
-- 4. Drop the new columns + their CHECK constraints.

View File

@@ -25,6 +25,16 @@
--
-- See docs/design-litigation-planner-2026-05-26.md §18.1.
-- ---------------------------------------------------------------
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
-- paliad.deadline_rules — step 4 reassigns 16 rules).
-- ---------------------------------------------------------------
SELECT set_config(
'paliad.audit_reason',
'mig 134: t-paliad-292 Slice B1 — Berufung unification, collapse 3 UPC appeal proceeding_types into upc.apl.unified + appeal_target discriminator',
true);
-- ---------------------------------------------------------------
-- 1. Schema additions
-- ---------------------------------------------------------------
@@ -86,7 +96,7 @@ INSERT INTO paliad.proceeding_types (
appeal_target
)
SELECT
'upc.apl',
'upc.apl.unified',
'Berufungsverfahren',
'Appeal',
'Vereinheitlichtes Berufungsverfahren — wählen Sie anschließend, '
@@ -120,10 +130,10 @@ DECLARE
BEGIN
SELECT id INTO upc_apl_id
FROM paliad.proceeding_types
WHERE code = 'upc.apl';
RAISE NOTICE '[mig 134] new upc.apl proceeding_type_id = %', upc_apl_id;
WHERE code = 'upc.apl.unified';
RAISE NOTICE '[mig 134] new upc.apl.unified proceeding_type_id = %', upc_apl_id;
RAISE NOTICE '[mig 134] Rules to reassign to upc.apl with applies_to_target:';
RAISE NOTICE '[mig 134] Rules to reassign to upc.apl.unified with applies_to_target:';
FOR rec IN
SELECT dr.id AS rule_id,
pt.code AS old_proceeding,
@@ -185,10 +195,10 @@ UPDATE paliad.deadline_rules dr
AND pt.code = 'upc.apl.order'
AND dr.is_active = true;
-- 4d. Reassign all 16 rules to the new upc.apl proceeding_type row.
-- 4d. Reassign all 16 rules to the new upc.apl.unified proceeding_type row.
UPDATE paliad.deadline_rules dr
SET proceeding_type_id = (
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl'
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.unified'
)
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
@@ -204,8 +214,7 @@ UPDATE paliad.deadline_rules dr
-- ---------------------------------------------------------------
UPDATE paliad.proceeding_types
SET is_active = false,
updated_at = now()
SET is_active = false
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
-- ---------------------------------------------------------------
@@ -221,10 +230,10 @@ BEGIN
SELECT COUNT(*) INTO unified_count
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl' AND dr.is_active = true;
RAISE NOTICE '[mig 134] post: rules on unified upc.apl = % (expected 16)', unified_count;
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true;
RAISE NOTICE '[mig 134] post: rules on unified upc.apl.unified = % (expected 16)', unified_count;
IF unified_count <> 16 THEN
RAISE EXCEPTION '[mig 134] FAILED — expected 16 rules on upc.apl, got %', unified_count;
RAISE EXCEPTION '[mig 134] FAILED — expected 16 rules on upc.apl.unified, got %', unified_count;
END IF;
SELECT COUNT(*) INTO archived_count
@@ -240,7 +249,7 @@ BEGIN
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl' AND dr.is_active = true
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true
GROUP BY unnest(applies_to_target)
ORDER BY 1
LOOP

View File

@@ -0,0 +1,8 @@
-- 135_primary_party_check — DOWN
--
-- Drops the CHECK constraint added in 135.up. No data revert needed
-- — the column stays text, the four-value vocab is enforced only by
-- application code thereafter.
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_primary_party_chk;

View File

@@ -0,0 +1,92 @@
-- 135_primary_party_check — Slice B3, m/paliad#124 §18.3
--
-- Tightens paliad.deadline_rules.primary_party from free-text to a
-- CHECK constraint over the canonical four-value vocabulary
-- (claimant / defendant / court / both). NULL stays valid for the
-- 78 cross-cutting orphan concept seeds (Wiedereinsetzung,
-- Versäumnisurteil-Einspruch, Schriftsatznachreichung,
-- Weiterbehandlung) — they have no proceeding_type_id binding so
-- they're outside the calculator's path; loosening the CHECK to
-- "IS NULL OR IN (…)" keeps them valid without backfill gymnastics.
--
-- Audit-first: the DO block RAISEs NOTICE for every non-conforming
-- row before adding the CHECK, and RAISEs EXCEPTION if any dirty
-- rows are found so the operator can decide a manual cleanup path.
-- Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
-- on the current corpus: 26 claimant + 26 defendant + 38 court +
-- 63 both + 78 NULL = 231 total, all in the canonical vocab. The
-- audit pass stays in the migration for safety against future drift
-- (e.g. a rule editor write that bypassed the application-layer
-- validation hook this slice also adds).
DO $$
DECLARE
rec record;
dirty_count int := 0;
BEGIN
RAISE NOTICE '[mig 135] primary_party audit pass — non-conforming rows:';
FOR rec IN
SELECT dr.id, dr.name, dr.primary_party,
pt.code AS proceeding_code
FROM paliad.deadline_rules dr
LEFT JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE dr.is_active = true
AND dr.primary_party IS NOT NULL
AND dr.primary_party NOT IN ('claimant', 'defendant', 'court', 'both')
ORDER BY pt.code NULLS LAST, dr.name
LOOP
RAISE NOTICE '[mig 135] % % primary_party=% (rule=%)',
COALESCE(rec.proceeding_code, '<orphan>'),
rec.name,
rec.primary_party,
rec.id;
dirty_count := dirty_count + 1;
END LOOP;
IF dirty_count > 0 THEN
RAISE EXCEPTION '[mig 135] FAILED — % rule(s) carry non-canonical primary_party values. '
'Manual cleanup required: update each row to one of '
'''claimant'', ''defendant'', ''court'', ''both'', or NULL. '
'See the NOTICE lines above for the offending rows.', dirty_count;
END IF;
RAISE NOTICE '[mig 135] audit clean — proceeding with CHECK constraint';
END $$;
-- ---------------------------------------------------------------
-- Add the CHECK constraint. NULL stays valid; the four canonical
-- values are the only allowed non-NULL forms.
-- ---------------------------------------------------------------
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')
);
COMMENT ON CONSTRAINT deadline_rules_primary_party_chk
ON paliad.deadline_rules IS
'Slice B3 (mig 135, m/paliad#124 §18.3) — canonical four-value '
'vocab for primary_party (claimant / defendant / court / both). '
'NULL allowed for cross-cutting orphan concept seeds (78 rows in '
'live corpus as of mig 135). See pkg/litigationplanner.PrimaryParties '
'for the in-code vocabulary.';
-- ---------------------------------------------------------------
-- Post-migration distribution check — informational NOTICE only.
-- ---------------------------------------------------------------
DO $$
DECLARE
rec record;
BEGIN
RAISE NOTICE '[mig 135] post: primary_party distribution after constraint add:';
FOR rec IN
SELECT COALESCE(primary_party, '<NULL>') AS party, COUNT(*) AS n
FROM paliad.deadline_rules
WHERE is_active = true
GROUP BY primary_party
ORDER BY party
LOOP
RAISE NOTICE '[mig 135] % count=%', rec.party, rec.n;
END LOOP;
END $$;

View File

@@ -0,0 +1,19 @@
-- 136_procedural_events_additive (down) — Slice B.1, t-paliad-273
--
-- Safe to run at any point in B.1's lifetime. Up does NOT touch
-- paliad.deadline_rules, so dropping the new tables + columns loses no
-- application data — every source row in deadline_rules is intact and
-- authoritative through the dual-write window.
--
-- Reverse order: drop indexes implicitly via DROP TABLE, drop the two
-- deadlines link columns first (their FKs target procedural_events +
-- sequencing_rules), then drop the three new tables in FK-safe order
-- (sequencing_rules → procedural_events → legal_sources).
ALTER TABLE paliad.deadlines
DROP COLUMN IF EXISTS procedural_event_id,
DROP COLUMN IF EXISTS sequencing_rule_id;
DROP TABLE IF EXISTS paliad.sequencing_rules;
DROP TABLE IF EXISTS paliad.procedural_events;
DROP TABLE IF EXISTS paliad.legal_sources;

View File

@@ -0,0 +1,488 @@
-- 136_procedural_events_additive — Slice B.1, t-paliad-273 / m/paliad#93
--
-- ADDITIVE ONLY. Creates the three new tables that split today's
-- paliad.deadline_rules into its three latent concepts (per the
-- 2026-05-25 inventor design + 2026-05-26 B.0 re-validation):
--
-- 1. paliad.legal_sources — the source-of-law citations
-- (DE.PatG.102, UPC.RoP.220.1, …)
-- 2. paliad.procedural_events — the procedural-event templates
-- (Rechtsbeschwerdebegründung, etc.;
-- successor of `submission_code`)
-- 3. paliad.sequencing_rules — the timing + trigger + condition
-- mechanics (today's per-row data)
--
-- and adds two nullable link columns on paliad.deadlines so B.2's
-- dual-write phase has somewhere to point.
--
-- The migration does NOT touch paliad.deadline_rules. The legacy table
-- stays intact and authoritative for reads until B.3 flips the cutover.
-- deadlines.rule_id stays in place (read by the calculator + projection
-- service). No app code is changed by this migration; B.2 introduces
-- the dual-write that wires services to the new tables.
--
-- Backfill plan (cf. design §5.1 + B.0 findings §7):
-- * legal_sources <- DISTINCT legal_source FROM deadline_rules WHERE
-- legal_source IS NOT NULL. pretty_de/pretty_en
-- LEFT NULL for now (legalSourcePretty() in Go
-- continues to materialise them on read; a future
-- slice backfills them via a Go shim).
-- * procedural_events <-
-- (a) DISTINCT ON (submission_code) FROM deadline_rules WHERE
-- submission_code IS NOT NULL — picks the lowest-id rule per
-- code as the procedural-event identity source.
-- (b) one synthetic procedural_event per NULL-submission_code
-- rule, code = 'null.' || substring(replace(id::text,'-',''),1,8).
-- m's pick (paliadin instruction 2026-05-26): mint synthetic
-- codes so every deadline_rules row ends up with a
-- procedural_events row, preserving the 1:1 sequencing-rule
-- backfill and keeping the NOT NULL FK on
-- sequencing_rules.procedural_event_id intact.
-- * sequencing_rules <- 1:1 from deadline_rules. The new row inherits
-- the source row's id so that any existing
-- paliad.deadlines.rule_id FK target stays resolvable through
-- the dual-write window (design §5.1 step 4).
-- * deadlines.procedural_event_id + sequencing_rule_id <- joined from
-- sequencing_rules on the inherited id.
--
-- Design deviations (intentional, documented):
-- - procedural_events.event_kind is NULLABLE (design proposed NOT NULL
-- with 'other' fallback). Today 89 deadline_rules rows have NULL
-- event_type — these are "structural / parent-only rows in the
-- proceeding tree" per B.0 §1. Forcing them to 'other' would lose
-- semantics. A later slice can tighten this to NOT NULL after the
-- 78+11 NULLs are reclassified.
-- - legal_sources.pretty_de / pretty_en are NULLABLE (design proposed
-- NOT NULL). Materialising them requires the Go-side
-- legalSourcePretty() function — out of scope for a SQL migration.
-- The Go read path continues to compute them on the fly from
-- legal_source / citation; a future slice (Go shim driven from
-- internal/services/submission_vars.go:619) backfills them.
-- - submission_drafts is NOT modified. The design proposes adding
-- procedural_event_id there too (§4.1 §5.1 step 6) but the B.1
-- instruction scope is explicit: tables + deadlines columns only.
-- submission_drafts continues to key off submission_code text.
--
-- Audit pattern follows mig 135 (Slice B3): PRE-pass counts what we
-- expect to write, BACKFILL runs the SELECT-INSERTs, POST-pass verifies
-- row counts and FK integrity. Any mismatch RAISE EXCEPTIONs and the
-- transaction rolls back — operator sees the NOTICE lines and the
-- failed assertion message.
--
-- See: docs/design-procedural-events-model-2026-05-25.md §4 + §5
-- docs/design-procedural-events-b0-findings-2026-05-26.md §7
-- ---------------------------------------------------------------
-- 0. PRE pass — snapshot what we're about to backfill
-- ---------------------------------------------------------------
DO $$
DECLARE
v_rules int;
v_codes_nn int;
v_codes_distinct int;
v_codes_null int;
v_legal_distinct int;
v_concept_linked int;
v_dups int;
BEGIN
SELECT COUNT(*) INTO v_rules FROM paliad.deadline_rules;
SELECT COUNT(*) INTO v_codes_nn FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
SELECT COUNT(DISTINCT submission_code) INTO v_codes_distinct
FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
SELECT COUNT(*) INTO v_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
SELECT COUNT(DISTINCT legal_source) INTO v_legal_distinct
FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
SELECT COUNT(*) INTO v_concept_linked FROM paliad.deadline_rules WHERE concept_id IS NOT NULL;
RAISE NOTICE '[mig 136] PRE: deadline_rules=%, with_submission_code=%, distinct_codes=%, null_codes=%, distinct_legal_sources=%, concept_linked=%',
v_rules, v_codes_nn, v_codes_distinct, v_codes_null, v_legal_distinct, v_concept_linked;
-- Defensive: refuse to run if multi-row submission_codes have crept
-- back in. B.0 (2026-05-26) found zero; mig 134 + 135 do not add
-- any. If this CHECK ever fires the backfill arithmetic below
-- breaks silently (one PE per code becomes ambiguous), so abort.
SELECT COUNT(*) INTO v_dups FROM (
SELECT submission_code
FROM paliad.deadline_rules
WHERE submission_code IS NOT NULL
GROUP BY submission_code
HAVING COUNT(*) > 1
) d;
IF v_dups > 0 THEN
RAISE EXCEPTION '[mig 136] FAILED PRE: % submission_code value(s) appear on >1 deadline_rules row. '
'The B.0 audit (2026-05-26) found zero. If you are seeing this, a rule was added that '
'duplicates an existing submission_code (or the _archived_litigation.* rows returned). '
'Decide whether the new schema collapses them (multiple sequencing rules → one '
'procedural event) or whether each row gets its own code, then update this migration '
'or the offending data before re-running.', v_dups;
END IF;
END $$;
-- ---------------------------------------------------------------
-- 1. CREATE TABLE paliad.legal_sources
-- ---------------------------------------------------------------
CREATE TABLE paliad.legal_sources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
citation text NOT NULL UNIQUE,
jurisdiction text NOT NULL,
pretty_de text,
pretty_en text,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE paliad.legal_sources IS
'Source-of-law citations (DE.PatG.102, UPC.RoP.220.1, …). One row per '
'distinct citation shorthand. pretty_de/pretty_en backfilled by a '
'future Go-driven slice; until then NULL and the Go service ('
'internal/services/submission_vars.go:619 legalSourcePretty) computes '
'the human-readable form on read from the citation. Slice B.1 t-paliad-273.';
CREATE INDEX legal_sources_jurisdiction_idx ON paliad.legal_sources(jurisdiction);
-- ---------------------------------------------------------------
-- 2. CREATE TABLE paliad.procedural_events
-- ---------------------------------------------------------------
CREATE TABLE paliad.procedural_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE,
name text NOT NULL,
name_en text NOT NULL DEFAULT '',
description text,
event_kind text,
primary_party_default text,
legal_source_id uuid REFERENCES paliad.legal_sources(id),
concept_id uuid REFERENCES paliad.deadline_concepts(id),
lifecycle_state text NOT NULL DEFAULT 'published',
draft_of uuid REFERENCES paliad.procedural_events(id),
published_at timestamptz,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE paliad.procedural_events IS
'Procedural-event templates — the "what kind of step is this in the '
'proceeding" hat of the legacy paliad.deadline_rules row. One row per '
'unique submission_code, plus one synthetic row per NULL-submission_code '
'rule (code prefix "null."). Slice B.1 t-paliad-273.';
COMMENT ON COLUMN paliad.procedural_events.event_kind IS
'filing|reply|hearing|decision|order|other. NULLABLE for now — 89 '
'rules in the live corpus have NULL event_type (structural / parent-only '
'rows in the proceeding tree). A future slice can tighten to NOT NULL '
'after these are reclassified.';
COMMENT ON COLUMN paliad.procedural_events.concept_id IS
'Optional reference to a deadline_concepts row. N:1 — one concept may '
'be shared by many procedural events (e.g. "Berufungsfrist" attaches to '
'all four court-specific Berufung procedural events). Do NOT add UNIQUE.';
CREATE INDEX procedural_events_concept_id_idx ON paliad.procedural_events(concept_id);
CREATE INDEX procedural_events_event_kind_idx ON paliad.procedural_events(event_kind);
CREATE INDEX procedural_events_lifecycle_idx ON paliad.procedural_events(lifecycle_state);
CREATE INDEX procedural_events_legal_source_idx ON paliad.procedural_events(legal_source_id);
-- ---------------------------------------------------------------
-- 3. CREATE TABLE paliad.sequencing_rules
-- ---------------------------------------------------------------
CREATE TABLE paliad.sequencing_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
procedural_event_id uuid NOT NULL REFERENCES paliad.procedural_events(id),
proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
parent_id uuid REFERENCES paliad.sequencing_rules(id),
trigger_event_id bigint REFERENCES paliad.trigger_events(id),
duration_value integer NOT NULL DEFAULT 0,
duration_unit text NOT NULL DEFAULT 'months',
timing text DEFAULT 'after',
alt_duration_value integer,
alt_duration_unit text,
alt_rule_code text,
anchor_alt text,
combine_op text,
condition_expr jsonb,
primary_party text,
sequence_order integer NOT NULL DEFAULT 0,
is_spawn boolean NOT NULL DEFAULT false,
spawn_label text,
spawn_proceeding_type_id integer REFERENCES paliad.proceeding_types(id),
is_bilateral boolean NOT NULL DEFAULT false,
is_court_set boolean NOT NULL DEFAULT false,
priority text NOT NULL DEFAULT 'mandatory',
rule_code text,
rule_codes text[],
deadline_notes text,
deadline_notes_en text,
choices_offered jsonb,
applies_to_target text[],
lifecycle_state text NOT NULL DEFAULT 'published',
draft_of uuid REFERENCES paliad.sequencing_rules(id),
published_at timestamptz,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE paliad.sequencing_rules IS
'Sequencing-rule mechanics — the "how and when does this fire" hat of '
'the legacy paliad.deadline_rules row. 1:1 with deadline_rules during '
'the dual-write window; the id is inherited from deadline_rules.id so '
'paliad.deadlines.rule_id FKs continue to resolve transitively. '
'Slice B.1 t-paliad-273.';
COMMENT ON COLUMN paliad.sequencing_rules.primary_party IS
'Per-rule override of procedural_events.primary_party_default. Same '
'four-value vocab as deadline_rules.primary_party (mig 135 CHECK). '
'NULL = use procedural-event default. A future slice can add the '
'same CHECK here.';
CREATE INDEX sequencing_rules_pe_proc_lifecycle_idx
ON paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state);
CREATE INDEX sequencing_rules_parent_id_idx ON paliad.sequencing_rules(parent_id);
CREATE INDEX sequencing_rules_trigger_event_idx ON paliad.sequencing_rules(trigger_event_id);
CREATE INDEX sequencing_rules_proceeding_type_idx ON paliad.sequencing_rules(proceeding_type_id);
-- ---------------------------------------------------------------
-- 4. ALTER paliad.deadlines — add link columns
-- ---------------------------------------------------------------
ALTER TABLE paliad.deadlines
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id),
ADD COLUMN sequencing_rule_id uuid REFERENCES paliad.sequencing_rules(id);
COMMENT ON COLUMN paliad.deadlines.procedural_event_id IS
'NULLABLE link to the procedural event this deadline instantiates. '
'Added Slice B.1 (mig 136). B.2 dual-write populates it on every new '
'deadline; B.3 cutover flips reads to use this instead of rule_id. '
'rule_id stays in place until B.4 destructive drop.';
COMMENT ON COLUMN paliad.deadlines.sequencing_rule_id IS
'NULLABLE link to the sequencing rule. Same lifecycle as '
'procedural_event_id — added Slice B.1, dual-written B.2, read in B.3, '
'rule_id dropped in B.4.';
CREATE INDEX deadlines_procedural_event_id_idx ON paliad.deadlines(procedural_event_id);
CREATE INDEX deadlines_sequencing_rule_id_idx ON paliad.deadlines(sequencing_rule_id);
-- ---------------------------------------------------------------
-- 5. BACKFILL — legal_sources
-- ---------------------------------------------------------------
INSERT INTO paliad.legal_sources (citation, jurisdiction)
SELECT DISTINCT
legal_source AS citation,
COALESCE(NULLIF(split_part(legal_source, '.', 1), ''), 'other') AS jurisdiction
FROM paliad.deadline_rules
WHERE legal_source IS NOT NULL;
-- ---------------------------------------------------------------
-- 6. BACKFILL — procedural_events
-- (a) codes-bearing branch: DISTINCT ON (submission_code) picks the
-- lowest-id (tie-break sequence_order) deadline_rules row as the
-- identity source per the design's §5.1 step 3.
-- (b) NULL-code branch: one synthetic row per rule, code minted from
-- the rule id's first 8 hex chars (sans dashes) — m's pick
-- 2026-05-26 (paliadin instruction).
-- ---------------------------------------------------------------
-- (a) codes-bearing rules → one procedural_events row per distinct code
INSERT INTO paliad.procedural_events
(code, name, name_en, description, event_kind, primary_party_default,
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
SELECT
src.submission_code,
src.name,
src.name_en,
src.description,
src.event_type,
src.primary_party,
ls.id,
src.concept_id,
src.lifecycle_state,
src.published_at,
src.is_active
FROM (
SELECT DISTINCT ON (submission_code)
submission_code, name, name_en, description, event_type,
primary_party, concept_id, legal_source, lifecycle_state,
published_at, is_active
FROM paliad.deadline_rules
WHERE submission_code IS NOT NULL
ORDER BY submission_code, id, sequence_order
) src
LEFT JOIN paliad.legal_sources ls ON ls.citation = src.legal_source;
-- (b) NULL-code rules → one synthetic procedural_events row each
INSERT INTO paliad.procedural_events
(code, name, name_en, description, event_kind, primary_party_default,
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
SELECT
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8) AS code,
dr.name,
dr.name_en,
dr.description,
dr.event_type,
dr.primary_party,
ls.id,
dr.concept_id,
dr.lifecycle_state,
dr.published_at,
dr.is_active
FROM paliad.deadline_rules dr
LEFT JOIN paliad.legal_sources ls ON ls.citation = dr.legal_source
WHERE dr.submission_code IS NULL;
-- ---------------------------------------------------------------
-- 7. BACKFILL — sequencing_rules
-- 1:1 with deadline_rules. id inherited so deadlines.rule_id FKs
-- continue to resolve through the dual-write window (design §5.1
-- step 4). procedural_event_id resolved by JOIN on the (real or
-- synthetic) code.
-- ---------------------------------------------------------------
INSERT INTO paliad.sequencing_rules
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
combine_op, condition_expr, primary_party, sequence_order,
is_spawn, spawn_label, spawn_proceeding_type_id,
is_bilateral, is_court_set, priority,
rule_code, rule_codes, deadline_notes, deadline_notes_en,
choices_offered, applies_to_target,
lifecycle_state, draft_of, published_at, is_active,
created_at, updated_at)
SELECT
dr.id,
pe.id,
dr.proceeding_type_id,
dr.parent_id,
dr.trigger_event_id,
dr.duration_value, dr.duration_unit, dr.timing,
dr.alt_duration_value, dr.alt_duration_unit, dr.alt_rule_code, dr.anchor_alt,
dr.combine_op, dr.condition_expr, dr.primary_party, dr.sequence_order,
dr.is_spawn, dr.spawn_label, dr.spawn_proceeding_type_id,
dr.is_bilateral, dr.is_court_set, dr.priority,
dr.rule_code, dr.rule_codes, dr.deadline_notes, dr.deadline_notes_en,
dr.choices_offered, dr.applies_to_target,
dr.lifecycle_state,
-- draft_of is a self-FK on deadline_rules; preserve as a self-FK on
-- sequencing_rules since the inherited ids are stable across both.
dr.draft_of,
dr.published_at, dr.is_active,
dr.created_at, dr.updated_at
FROM paliad.deadline_rules dr
JOIN paliad.procedural_events pe
ON pe.code = COALESCE(
dr.submission_code,
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)
);
-- ---------------------------------------------------------------
-- 8. BACKFILL — paliad.deadlines link columns
-- ---------------------------------------------------------------
UPDATE paliad.deadlines d
SET procedural_event_id = sr.procedural_event_id,
sequencing_rule_id = sr.id
FROM paliad.sequencing_rules sr
WHERE d.rule_id = sr.id;
-- ---------------------------------------------------------------
-- 9. POST pass — integrity assertions
-- ---------------------------------------------------------------
DO $$
DECLARE
v_dr_total int;
v_dr_codes_distinct int;
v_dr_codes_null int;
v_dr_legal_distinct int;
v_pe_total int;
v_sr_total int;
v_ls_total int;
v_orphan_pe int;
v_dup_synthetic int;
v_deadlines_linked int;
v_deadlines_total int;
v_pe_missing_ls int;
BEGIN
SELECT COUNT(*) INTO v_dr_total FROM paliad.deadline_rules;
SELECT COUNT(DISTINCT submission_code)
INTO v_dr_codes_distinct FROM paliad.deadline_rules WHERE submission_code IS NOT NULL;
SELECT COUNT(*) INTO v_dr_codes_null FROM paliad.deadline_rules WHERE submission_code IS NULL;
SELECT COUNT(DISTINCT legal_source)
INTO v_dr_legal_distinct FROM paliad.deadline_rules WHERE legal_source IS NOT NULL;
SELECT COUNT(*) INTO v_pe_total FROM paliad.procedural_events;
SELECT COUNT(*) INTO v_sr_total FROM paliad.sequencing_rules;
SELECT COUNT(*) INTO v_ls_total FROM paliad.legal_sources;
SELECT COUNT(*) INTO v_deadlines_total FROM paliad.deadlines;
SELECT COUNT(*) INTO v_deadlines_linked FROM paliad.deadlines WHERE procedural_event_id IS NOT NULL;
-- a. procedural_events row count = distinct_codes + null_codes
IF v_pe_total <> v_dr_codes_distinct + v_dr_codes_null THEN
RAISE EXCEPTION '[mig 136] FAILED POST: procedural_events count mismatch — got %, expected % (% distinct codes + % null-code rules)',
v_pe_total, v_dr_codes_distinct + v_dr_codes_null, v_dr_codes_distinct, v_dr_codes_null;
END IF;
-- b. sequencing_rules row count = deadline_rules row count (1:1)
IF v_sr_total <> v_dr_total THEN
RAISE EXCEPTION '[mig 136] FAILED POST: sequencing_rules count mismatch — got %, expected % (1:1 with deadline_rules)',
v_sr_total, v_dr_total;
END IF;
-- c. legal_sources row count = distinct legal_source in deadline_rules
IF v_ls_total <> v_dr_legal_distinct THEN
RAISE EXCEPTION '[mig 136] FAILED POST: legal_sources count mismatch — got %, expected % (distinct legal_source)',
v_ls_total, v_dr_legal_distinct;
END IF;
-- d. every sequencing_rules row's procedural_event_id resolves
SELECT COUNT(*)
INTO v_orphan_pe
FROM paliad.sequencing_rules sr
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE pe.id IS NULL;
IF v_orphan_pe > 0 THEN
RAISE EXCEPTION '[mig 136] FAILED POST: % sequencing_rules row(s) have no resolving procedural_event_id', v_orphan_pe;
END IF;
-- e. no two synthetic codes collide (would have crashed the INSERT
-- via UNIQUE, but assert again for clarity — collision among 78
-- UUIDs at 8 hex chars is ~6e-7 probability)
SELECT COUNT(*)
INTO v_dup_synthetic
FROM (
SELECT code, COUNT(*) AS n
FROM paliad.procedural_events
WHERE code LIKE 'null.%'
GROUP BY code
HAVING COUNT(*) > 1
) d;
IF v_dup_synthetic > 0 THEN
RAISE EXCEPTION '[mig 136] FAILED POST: % synthetic codes collided. '
'Re-run with a longer substring (16 hex chars instead of 8) '
'or full uuid in the code-mint expression.', v_dup_synthetic;
END IF;
-- f. every procedural_events.legal_source_id either resolves or is
-- NULL (NULL is fine — 119 of 231 rules have NULL legal_source)
SELECT COUNT(*)
INTO v_pe_missing_ls
FROM paliad.procedural_events pe
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
WHERE pe.legal_source_id IS NOT NULL
AND ls.id IS NULL;
IF v_pe_missing_ls > 0 THEN
RAISE EXCEPTION '[mig 136] FAILED POST: % procedural_events row(s) reference a missing legal_sources id', v_pe_missing_ls;
END IF;
RAISE NOTICE '[mig 136] POST: legal_sources=%, procedural_events=%, sequencing_rules=%, deadlines=% (% linked)',
v_ls_total, v_pe_total, v_sr_total, v_deadlines_total, v_deadlines_linked;
RAISE NOTICE '[mig 136] integrity OK — backfill complete. '
'deadline_rules untouched (1:1 with sequencing_rules; '
'ready for B.2 dual-write).';
END $$;

View File

@@ -0,0 +1,18 @@
-- 137_proceeding_role_labels — DOWN
--
-- Drops the 4 role-label columns. Backfilled data is lost on
-- down-migration; that's acceptable because the frontend renderer
-- falls back to the default labels ("Klägerseite" / "Beklagtenseite")
-- when the columns are absent.
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS role_reactive_label_en;
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS role_reactive_label_de;
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS role_proactive_label_en;
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS role_proactive_label_de;

View File

@@ -0,0 +1,137 @@
-- 137_proceeding_role_labels — t-paliad-301, m/paliad#132
--
-- Bug A fix: per-proceeding role labels so the Verfahrensablauf side
-- selector can render "Berufungskläger / Berufungsbeklagter" for the
-- unified UPC Berufung tile instead of the generic "Klägerseite /
-- Beklagtenseite".
--
-- Four new optional columns on paliad.proceeding_types. NULL on a
-- column falls back to the language-default ("Klägerseite" / "Claimant
-- side" / "Beklagtenseite" / "Defendant side") in the frontend renderer.
-- Only the proceedings whose role-naming actually differs get a backfill.
--
-- Live-DB audit (mcp__supabase__execute_sql) before drafting:
-- - paliad.proceeding_types has 14 columns; the 4 target columns do
-- NOT exist (zero name collisions).
-- - Zero triggers on paliad.proceeding_types. No audit_reason
-- setup needed.
-- - No updated_at / created_at on the table — DO NOT include
-- timestamp UPDATEs (lesson from mig 134 HOTFIX 3).
--
-- ADDITIVE ONLY. ALTER + UPDATE statements; no CHECK constraints
-- (the columns are free-text labels, validated at the application layer).
-- Down migration drops the 4 columns.
--
-- See m/paliad#132 for the full design rationale + the role-label
-- matrix per proceeding code.
-- ---------------------------------------------------------------
-- 1. Schema additions
-- ---------------------------------------------------------------
ALTER TABLE paliad.proceeding_types
ADD COLUMN role_proactive_label_de text NULL;
ALTER TABLE paliad.proceeding_types
ADD COLUMN role_proactive_label_en text NULL;
ALTER TABLE paliad.proceeding_types
ADD COLUMN role_reactive_label_de text NULL;
ALTER TABLE paliad.proceeding_types
ADD COLUMN role_reactive_label_en text NULL;
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_de IS
'DE label for the proactive (claimant-equivalent) side of this '
'proceeding. NULL = renderer falls back to "Klägerseite". '
't-paliad-301 / m/paliad#132 Bug A.';
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_en IS
'EN label for the proactive side. NULL = "Claimant side".';
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_de IS
'DE label for the reactive (defendant-equivalent) side. NULL = '
'"Beklagtenseite".';
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_en IS
'EN label for the reactive side. NULL = "Defendant side".';
-- ---------------------------------------------------------------
-- 2. Audit-first NOTICE pass.
--
-- Lists which proceeding_types are about to receive a backfill so
-- the operator sees the scope before the UPDATE fires. NULL columns
-- on every other row stay NULL (the frontend falls back to defaults).
-- ---------------------------------------------------------------
DO $$
DECLARE
rec record;
backfill_count int := 0;
BEGIN
RAISE NOTICE '[mig 137] Proceedings that will receive role-label backfill:';
FOR rec IN
SELECT code, name
FROM paliad.proceeding_types
WHERE code IN ('upc.apl.unified', 'upc.rev.cfi', 'epa.opp.opd', 'epa.opp.boa')
ORDER BY code
LOOP
RAISE NOTICE '[mig 137] % %', rec.code, rec.name;
backfill_count := backfill_count + 1;
END LOOP;
RAISE NOTICE '[mig 137] Total: % proceedings (others stay NULL → renderer default)', backfill_count;
END $$;
-- ---------------------------------------------------------------
-- 3. Backfill.
--
-- Per the design matrix in m/paliad#132:
-- - upc.apl.unified → Berufungskläger / Berufungsbeklagter / Appellant / Appellee
-- - upc.rev.cfi → Antragsteller (Nichtigkeit) / Antragsgegner (Nichtigkeit) /
-- Revocation claimant / Revocation defendant
-- - epa.opp.opd → Einsprechende(r) / Patentinhaber(in) /
-- Opponent / Patentee
-- - epa.opp.boa → Einsprechende(r) / Patentinhaber(in) /
-- Opponent / Patentee
-- - (others) → stay NULL → frontend defaults
-- ---------------------------------------------------------------
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Berufungskläger',
role_reactive_label_de = 'Berufungsbeklagter',
role_proactive_label_en = 'Appellant',
role_reactive_label_en = 'Appellee'
WHERE code = 'upc.apl.unified';
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Antragsteller (Nichtigkeit)',
role_reactive_label_de = 'Antragsgegner (Nichtigkeit)',
role_proactive_label_en = 'Revocation claimant',
role_reactive_label_en = 'Revocation defendant'
WHERE code = 'upc.rev.cfi';
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Einsprechende(r)',
role_reactive_label_de = 'Patentinhaber(in)',
role_proactive_label_en = 'Opponent',
role_reactive_label_en = 'Patentee'
WHERE code IN ('epa.opp.opd', 'epa.opp.boa');
-- ---------------------------------------------------------------
-- 4. Post-migration NOTICE — informational only.
-- ---------------------------------------------------------------
DO $$
DECLARE
rec record;
BEGIN
RAISE NOTICE '[mig 137] post: backfilled role-label distribution:';
FOR rec IN
SELECT code,
role_proactive_label_de,
role_reactive_label_de
FROM paliad.proceeding_types
WHERE role_proactive_label_de IS NOT NULL
ORDER BY code
LOOP
RAISE NOTICE '[mig 137] % proactive=% reactive=%',
rec.code, rec.role_proactive_label_de, rec.role_reactive_label_de;
END LOOP;
END $$;

View File

@@ -0,0 +1,71 @@
-- 138_appeal_target_backfill_merits_order DOWN — t-paliad-303, m/paliad#134
--
-- Removes 'schadensbemessung' from the merits-track rules and
-- 'bucheinsicht' from the order-track rules, restoring the pre-137
-- shape (endentscheidung-only / anordnung-only / kostenentscheidung-only).
-- ---------------------------------------------------------------
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
-- paliad.deadline_rules).
-- ---------------------------------------------------------------
SELECT set_config(
'paliad.audit_reason',
'mig 138 DOWN: t-paliad-303 — strip Schadensbemessung/Bucheinsicht from applies_to_target per m/paliad#134',
true);
-- ---------------------------------------------------------------
-- 1. Strip new targets via array_remove.
--
-- WHERE clauses pinned to upc.apl.unified to avoid touching unrelated
-- rules that might have been added later under other proceeding types.
-- ---------------------------------------------------------------
-- 1a. Remove schadensbemessung from merits-track rows.
UPDATE paliad.deadline_rules dr
SET applies_to_target = array_remove(dr.applies_to_target, 'schadensbemessung')
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'schadensbemessung' = ANY(dr.applies_to_target);
-- 1b. Remove bucheinsicht from order-track rows.
UPDATE paliad.deadline_rules dr
SET applies_to_target = array_remove(dr.applies_to_target, 'bucheinsicht')
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'bucheinsicht' = ANY(dr.applies_to_target);
-- ---------------------------------------------------------------
-- 2. Sanity check — no row may carry the new targets after the down.
-- ---------------------------------------------------------------
DO $$
DECLARE
schad_left int;
buch_left int;
BEGIN
SELECT COUNT(*) INTO schad_left
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'schadensbemessung' = ANY(dr.applies_to_target);
SELECT COUNT(*) INTO buch_left
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'bucheinsicht' = ANY(dr.applies_to_target);
IF schad_left > 0 THEN
RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry schadensbemessung', schad_left;
END IF;
IF buch_left > 0 THEN
RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry bucheinsicht', buch_left;
END IF;
RAISE NOTICE '[mig 138 DOWN] stripped schadensbemessung + bucheinsicht from upc.apl.unified rules';
END $$;

View File

@@ -0,0 +1,232 @@
-- 138_appeal_target_backfill_merits_order — t-paliad-303, m/paliad#134
--
-- Slice B1 (mig 134) introduced the unified upc.apl.unified proceeding type
-- with 5 appeal_target enum values: endentscheidung, kostenentscheidung,
-- anordnung, schadensbemessung, bucheinsicht. The first three each carry
-- rules; schadensbemessung and bucheinsicht returned an empty timeline
-- because no rules referenced them yet.
--
-- m's 2026-05-26 decision (#134): extend applies_to_target on the existing
-- rules — Schadensbemessung := merits track (R.224 anchored on R.118
-- substantive decisions), Bucheinsicht := order track (R.220.2 +
-- R.224.2.b + R.235.2 + R.237 + R.238.2 etc.). Legal premise verified
-- against the 16 live rules — every endentscheidung rule is a generic
-- R.224 merits step, every anordnung rule is a generic R.220/224/235/237/
-- 238 order step. No rule carries content specific to a particular kind
-- of underlying decision/order. Audit on the comment trail of #134.
-- ---------------------------------------------------------------
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
-- paliad.deadline_rules — both UPDATEs below trigger it).
-- ---------------------------------------------------------------
SELECT set_config(
'paliad.audit_reason',
'mig 138: t-paliad-303 — extend applies_to_target for Schadensbemessung (merits) + Bucheinsicht (order) per m/paliad#134',
true);
-- ---------------------------------------------------------------
-- 1. Audit-first DO block.
--
-- Resolve upc.apl.unified, count the rows we are about to touch, and
-- RAISE EXCEPTION if anything looks wrong (proceeding type missing,
-- merits/order rule counts off, or a rule already carries the new
-- target — which would mean an earlier partial run).
-- ---------------------------------------------------------------
DO $$
DECLARE
rec record;
upc_apl_id int;
merits_count int;
order_count int;
schad_already int;
buch_already int;
BEGIN
SELECT id INTO upc_apl_id
FROM paliad.proceeding_types
WHERE code = 'upc.apl.unified';
IF upc_apl_id IS NULL THEN
RAISE EXCEPTION '[mig 138] upc.apl.unified proceeding_type not found — mig 134 must run first';
END IF;
RAISE NOTICE '[mig 138] upc.apl.unified proceeding_type_id = %', upc_apl_id;
SELECT COUNT(*) INTO merits_count
FROM paliad.deadline_rules
WHERE proceeding_type_id = upc_apl_id
AND is_active = true
AND 'endentscheidung' = ANY(applies_to_target);
SELECT COUNT(*) INTO order_count
FROM paliad.deadline_rules
WHERE proceeding_type_id = upc_apl_id
AND is_active = true
AND 'anordnung' = ANY(applies_to_target);
RAISE NOTICE '[mig 138] live counts: endentscheidung=% anordnung=%', merits_count, order_count;
IF merits_count <> 7 THEN
RAISE EXCEPTION '[mig 138] expected 7 endentscheidung rules under upc.apl.unified, got %', merits_count;
END IF;
IF order_count <> 7 THEN
RAISE EXCEPTION '[mig 138] expected 7 anordnung rules under upc.apl.unified, got %', order_count;
END IF;
SELECT COUNT(*) INTO schad_already
FROM paliad.deadline_rules
WHERE proceeding_type_id = upc_apl_id
AND is_active = true
AND 'schadensbemessung' = ANY(applies_to_target);
SELECT COUNT(*) INTO buch_already
FROM paliad.deadline_rules
WHERE proceeding_type_id = upc_apl_id
AND is_active = true
AND 'bucheinsicht' = ANY(applies_to_target);
IF schad_already > 0 THEN
RAISE EXCEPTION '[mig 138] % rules already carry schadensbemessung — partial run?', schad_already;
END IF;
IF buch_already > 0 THEN
RAISE EXCEPTION '[mig 138] % rules already carry bucheinsicht — partial run?', buch_already;
END IF;
RAISE NOTICE '[mig 138] rules to extend with schadensbemessung (merits track):';
FOR rec IN
SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target
FROM paliad.deadline_rules dr
WHERE dr.proceeding_type_id = upc_apl_id
AND dr.is_active = true
AND 'endentscheidung' = ANY(dr.applies_to_target)
ORDER BY dr.sequence_order, dr.rule_code NULLS LAST
LOOP
RAISE NOTICE '[mig 138] merits % % % pre=% → post=%',
COALESCE(rec.rule_code, '(no-code)'),
COALESCE(rec.legal_source, '(no-source)'),
rec.name,
rec.applies_to_target,
rec.applies_to_target || 'schadensbemessung'::text;
END LOOP;
RAISE NOTICE '[mig 138] rules to extend with bucheinsicht (order track):';
FOR rec IN
SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target
FROM paliad.deadline_rules dr
WHERE dr.proceeding_type_id = upc_apl_id
AND dr.is_active = true
AND 'anordnung' = ANY(dr.applies_to_target)
ORDER BY dr.sequence_order, dr.rule_code NULLS LAST
LOOP
RAISE NOTICE '[mig 138] order % % % pre=% → post=%',
COALESCE(rec.rule_code, '(no-code)'),
COALESCE(rec.legal_source, '(no-source)'),
rec.name,
rec.applies_to_target,
rec.applies_to_target || 'bucheinsicht'::text;
END LOOP;
END $$;
-- ---------------------------------------------------------------
-- 2. Extend applies_to_target.
--
-- Narrow WHERE clauses key off upc.apl.unified + existing target +
-- absence of new target, so the UPDATEs are idempotent in spirit
-- (the audit block above already RAISE EXCEPTIONed if any row
-- already had the new value).
-- ---------------------------------------------------------------
-- 2a. Schadensbemessung := merits track (7 rules expected).
UPDATE paliad.deadline_rules dr
SET applies_to_target = applies_to_target || 'schadensbemessung'::text
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'endentscheidung' = ANY(dr.applies_to_target)
AND NOT ('schadensbemessung' = ANY(dr.applies_to_target));
-- 2b. Bucheinsicht := order track (7 rules expected).
UPDATE paliad.deadline_rules dr
SET applies_to_target = applies_to_target || 'bucheinsicht'::text
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'anordnung' = ANY(dr.applies_to_target)
AND NOT ('bucheinsicht' = ANY(dr.applies_to_target));
-- ---------------------------------------------------------------
-- 3. Post-migration sanity check.
--
-- Hard-fail on any divergence: the two new targets must each cover
-- 7 rules, the original three targets must be unchanged in count,
-- and no rule has lost its prior target.
-- ---------------------------------------------------------------
DO $$
DECLARE
schad_post int;
buch_post int;
end_post int;
anord_post int;
cost_post int;
target_distribution record;
BEGIN
SELECT COUNT(*) INTO schad_post
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'schadensbemessung' = ANY(dr.applies_to_target);
SELECT COUNT(*) INTO buch_post
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'bucheinsicht' = ANY(dr.applies_to_target);
SELECT COUNT(*) INTO end_post
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'endentscheidung' = ANY(dr.applies_to_target);
SELECT COUNT(*) INTO anord_post
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'anordnung' = ANY(dr.applies_to_target);
SELECT COUNT(*) INTO cost_post
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl.unified'
AND dr.is_active = true
AND 'kostenentscheidung' = ANY(dr.applies_to_target);
RAISE NOTICE '[mig 138] post: schadensbemessung=% bucheinsicht=% endentscheidung=% anordnung=% kostenentscheidung=%',
schad_post, buch_post, end_post, anord_post, cost_post;
IF schad_post <> 7 THEN
RAISE EXCEPTION '[mig 138] FAILED — expected 7 schadensbemessung rules, got %', schad_post;
END IF;
IF buch_post <> 7 THEN
RAISE EXCEPTION '[mig 138] FAILED — expected 7 bucheinsicht rules, got %', buch_post;
END IF;
IF end_post <> 7 THEN
RAISE EXCEPTION '[mig 138] FAILED — endentscheidung count drifted: expected 7, got %', end_post;
END IF;
IF anord_post <> 7 THEN
RAISE EXCEPTION '[mig 138] FAILED — anordnung count drifted: expected 7, got %', anord_post;
END IF;
IF cost_post <> 2 THEN
RAISE EXCEPTION '[mig 138] FAILED — kostenentscheidung count drifted: expected 2, got %', cost_post;
END IF;
FOR target_distribution IN
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true
GROUP BY unnest(applies_to_target)
ORDER BY 1
LOOP
RAISE NOTICE '[mig 138] post: applies_to_target=% count=%',
target_distribution.target, target_distribution.n;
END LOOP;
END $$;

View File

@@ -40,7 +40,9 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active,
trigger_event_label_de, trigger_event_label_en,
appeal_target`
appeal_target,
role_proactive_label_de, role_proactive_label_en,
role_reactive_label_de, role_reactive_label_en`
// List returns active rules, optionally filtered by proceeding type.
// Each row has ConceptDefaultEventTypeID hydrated from

View File

@@ -15,16 +15,6 @@ import (
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// isValidPartyForLookup mirrors the four-value primary_party vocab the
// engine knows about. B2 inlines this check; B3 will add a canonical
// lp.IsValidPrimaryParty + tighten the column to a CHECK constraint.
func isValidPartyForLookup(s string) bool {
switch s {
case "claimant", "defendant", "court", "both":
return true
}
return false
}
// FristenrechnerService renders the Paliad public Fristenrechner's
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
@@ -268,7 +258,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe
jurisdiction = ""
}
party := axes.Party
if party != "" && !isValidPartyForLookup(party) {
if party != "" && !lp.IsValidPrimaryParty(party) {
party = ""
}
appealTarget := axes.AppealTarget

View File

@@ -137,14 +137,14 @@ func TestLookupEvents(t *testing.T) {
t.Errorf("anchor row %s missing endentscheidung target: %v",
m.Rule.Name, m.Rule.AppliesToTarget)
}
if m.ProceedingType.Code != "upc.apl" {
t.Errorf("anchor row %s came from %s, want upc.apl",
if m.ProceedingType.Code != "upc.apl.unified" {
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
m.Rule.Name, m.ProceedingType.Code)
}
}
})
t.Run("appeal_target=schadensbemessung returns empty (no rules seeded yet)", func(t *testing.T) {
t.Run("appeal_target=schadensbemessung returns upc.apl merits rules (mig 138 backfill)", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
AppealTarget: lp.AppealTargetSchadensbemessung,
@@ -152,8 +152,68 @@ func TestLookupEvents(t *testing.T) {
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
if len(matches) != 0 {
t.Errorf("schadensbemessung should be empty until rules seeded; got %d rows", len(matches))
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 merits-track
// rules under upc.apl.unified with applies_to_target ⊇ {schadensbemessung}
// because R.224 is uniform across substantive R.118 decisions.
if len(matches) == 0 {
t.Fatal("expected upc.apl schadensbemessung rules after mig 138 backfill")
}
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue
}
found := false
for _, t := range m.Rule.AppliesToTarget {
if t == lp.AppealTargetSchadensbemessung {
found = true
break
}
}
if !found {
t.Errorf("anchor row %s missing schadensbemessung target: %v",
m.Rule.Name, m.Rule.AppliesToTarget)
}
if m.ProceedingType.Code != "upc.apl.unified" {
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
m.Rule.Name, m.ProceedingType.Code)
}
}
})
t.Run("appeal_target=bucheinsicht returns upc.apl order rules (mig 138 backfill)", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
AppealTarget: lp.AppealTargetBucheinsicht,
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 order-track
// rules under upc.apl.unified with applies_to_target ⊇ {bucheinsicht}
// because R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are
// uniform across the orders they appeal.
if len(matches) == 0 {
t.Fatal("expected upc.apl bucheinsicht rules after mig 138 backfill")
}
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue
}
found := false
for _, t := range m.Rule.AppliesToTarget {
if t == lp.AppealTargetBucheinsicht {
found = true
break
}
}
if !found {
t.Errorf("anchor row %s missing bucheinsicht target: %v",
m.Rule.Name, m.Rule.AppliesToTarget)
}
if m.ProceedingType.Code != "upc.apl.unified" {
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
m.Rule.Name, m.ProceedingType.Code)
}
}
})
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// RuleEditorService owns the admin-only rule lifecycle for Phase 3
@@ -148,6 +149,16 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
if strings.TrimSpace(input.Priority) == "" {
input.Priority = "mandatory"
}
// Slice B3 (m/paliad#124 §18.3, mig 135): canonical four-value
// primary_party vocab. Pre-validate so the user gets a
// user-friendly error before the DB CHECK fires with the raw
// constraint-violation message.
if input.PrimaryParty != nil && !lp.IsValidPrimaryParty(*input.PrimaryParty) {
return nil, fmt.Errorf(
"%w: primary_party=%q is not one of %v",
ErrInvalidInput, *input.PrimaryParty, lp.PrimaryParties,
)
}
if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil {
return nil, err
}
@@ -220,6 +231,19 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
ErrInvalidLifecycleState, id, current.LifecycleState)
}
// Slice B3 (m/paliad#124 §18.3, mig 135): pre-validate the
// patch's primary_party so the user gets a user-friendly error
// before the DB CHECK fires with the raw constraint-violation
// message. Patch field is *string — nil means "don't change",
// dereferenced empty string means "set to NULL" (handled below
// in buildPatchSets).
if patch.PrimaryParty != nil && !lp.IsValidPrimaryParty(*patch.PrimaryParty) {
return nil, fmt.Errorf(
"%w: primary_party=%q is not one of %v",
ErrInvalidInput, *patch.PrimaryParty, lp.PrimaryParties,
)
}
// Spawn cycle guard: if the patch sets spawn_proceeding_type_id,
// validate against the global graph BEFORE the UPDATE so we can
// surface the cycle clearly instead of relying on a runtime

View File

@@ -0,0 +1,55 @@
package litigationplanner
import "testing"
// TestTriggerEventLabelForAppealTarget pins the per-target trigger-
// event label matrix (t-paliad-301 / m/paliad#132 Bug B). The 5
// canonical AppealTargets each have a DE + EN label; unknown targets
// return empty so the caller can fall back to the proceeding's own
// trigger_event_label.
func TestTriggerEventLabelForAppealTarget(t *testing.T) {
cases := []struct {
target string
lang string
want string
}{
{AppealTargetEndentscheidung, "de", "Endentscheidung (R.118)"},
{AppealTargetEndentscheidung, "en", "Final decision (R.118)"},
{AppealTargetKostenentscheidung, "de", "Kostenentscheidung"},
{AppealTargetKostenentscheidung, "en", "Cost decision"},
{AppealTargetAnordnung, "de", "Anordnung"},
{AppealTargetAnordnung, "en", "Order"},
{AppealTargetSchadensbemessung, "de", "Entscheidung im Schadensbemessungsverfahren"},
{AppealTargetSchadensbemessung, "en", "Damages-assessment decision"},
{AppealTargetBucheinsicht, "de", "Anordnung der Bucheinsicht"},
{AppealTargetBucheinsicht, "en", "Book-inspection order"},
// Unknown lang falls through to DE so the caller never gets
// an empty string for a known target.
{AppealTargetEndentscheidung, "fr", "Endentscheidung (R.118)"},
// Unknown target → empty so caller falls back to proceeding's
// trigger_event_label.
{"", "de", ""},
{"foo", "en", ""},
}
for _, c := range cases {
if got := TriggerEventLabelForAppealTarget(c.target, c.lang); got != c.want {
t.Errorf("TriggerEventLabelForAppealTarget(%q, %q) = %q, want %q",
c.target, c.lang, got, c.want)
}
}
}
// TestAppealTargetsCoverage ensures every entry in AppealTargets has
// a non-empty label in both languages. Adding a target to the slice
// without populating the switch would silently emit empty labels —
// this test catches that.
func TestAppealTargetsCoverage(t *testing.T) {
for _, target := range AppealTargets {
for _, lang := range []string{"de", "en"} {
if got := TriggerEventLabelForAppealTarget(target, lang); got == "" {
t.Errorf("AppealTarget %q has empty label for lang %q — add it to the switch",
target, lang)
}
}
}
}

View File

@@ -0,0 +1,66 @@
package upc
import (
"fmt"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// SnapshotCourt is the embedded court row shape. Mirrors paliad.courts.
type SnapshotCourt struct {
ID string `json:"id"`
Code string `json:"code"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
Country string `json:"country"`
Regime *string `json:"regime,omitempty"`
CourtType string `json:"court_type"`
ParentID *string `json:"parent_id,omitempty"`
SortOrder int `json:"sort_order"`
}
// SnapshotCourtRegistry serves CourtRegistry against the embedded
// court slice. UPC subset only (DE / EPA / DPMA courts are NOT in
// the snapshot — youpc.org has no need for them, and a request for
// a non-UPC court id falls through to default country/regime per the
// CountryRegime contract).
type SnapshotCourtRegistry struct {
byID map[string]SnapshotCourt
}
// NewCourtRegistry parses the embedded courts.json and returns a
// ready-to-use registry.
func NewCourtRegistry() (*SnapshotCourtRegistry, error) {
var courts []SnapshotCourt
if err := readJSON("courts.json", &courts); err != nil {
return nil, err
}
r := &SnapshotCourtRegistry{byID: make(map[string]SnapshotCourt, len(courts))}
for _, c := range courts {
r.byID[c.ID] = c
}
return r, nil
}
// CountryRegime resolves a court ID to its (country, regime) tuple.
// Empty courtID falls back to (defaultCountry, defaultRegime) per the
// interface contract. ErrUnknownCourt-equivalent (a plain error here)
// when courtID is non-empty but absent from the snapshot.
func (r *SnapshotCourtRegistry) CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error) {
if courtID == "" {
return defaultCountry, defaultRegime, nil
}
c, ok := r.byID[courtID]
if !ok {
return "", "", fmt.Errorf("upc snapshot: unknown court id %q", courtID)
}
reg := ""
if c.Regime != nil {
reg = *c.Regime
}
return c.Country, reg, nil
}
// Compile-time assertion that SnapshotCourtRegistry satisfies
// lp.CourtRegistry.
var _ lp.CourtRegistry = (*SnapshotCourtRegistry)(nil)

View File

@@ -0,0 +1,22 @@
[
{
"id": "upc-ld-munich",
"code": "upc-ld-munich",
"name_de": "UPC Lokalkammer München",
"name_en": "UPC Local Division Munich",
"country": "DE",
"regime": "UPC",
"court_type": "upc-ld",
"sort_order": 10
},
{
"id": "upc-coa",
"code": "upc-coa",
"name_de": "UPC Berufungsgericht",
"name_en": "UPC Court of Appeal",
"country": "LU",
"regime": "UPC",
"court_type": "upc-coa",
"sort_order": 100
}
]

View File

@@ -0,0 +1,80 @@
// Package upc provides an embedded, DB-free implementation of the
// litigationplanner Catalog / HolidayCalendar / CourtRegistry
// interfaces, populated from a JSON snapshot of paliad's UPC rule
// corpus.
//
// Slice C of the litigation-planner extraction (m/paliad#124 §19).
//
// Consumers (today: youpc.org; future: any third-party UPC tool) wire
// the engine like this:
//
// import (
// lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
// upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"
// )
//
// cat, _ := upc.NewCatalog()
// hc, _ := upc.NewHolidayCalendar()
// cr, _ := upc.NewCourtRegistry()
//
// timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
// lp.CalcOptions{}, cat, hc, cr)
//
// Regenerating the snapshot: see cmd/gen-upc-snapshot/README.md.
//
//go:generate sh -c "echo 'snapshot is regenerated via the gen-upc-snapshot binary — see cmd/gen-upc-snapshot/README.md'"
package upc
import (
"embed"
"encoding/json"
"fmt"
"time"
)
// rawFS holds the snapshot JSON files. The data files are produced by
// cmd/gen-upc-snapshot from a paliad live DB.
//
//go:embed *.json
var rawFS embed.FS
// Meta is the version block from meta.json.
type Meta struct {
Version string `json:"version"`
GeneratedAt time.Time `json:"generated_at"`
PaliadCommit string `json:"paliad_commit,omitempty"`
SourceDBLabel string `json:"source_db_label,omitempty"`
RuleCount int `json:"rule_count"`
ProceedingCount int `json:"proceeding_count"`
TriggerEventCount int `json:"trigger_event_count"`
HolidayCount int `json:"holiday_count"`
CourtCount int `json:"court_count"`
}
// LoadMeta parses meta.json from the embedded snapshot. Returns an
// error when the snapshot hasn't been generated yet (meta.json
// missing or empty).
func LoadMeta() (Meta, error) {
var m Meta
buf, err := rawFS.ReadFile("meta.json")
if err != nil {
return Meta{}, fmt.Errorf("read meta.json: %w", err)
}
if err := json.Unmarshal(buf, &m); err != nil {
return Meta{}, fmt.Errorf("decode meta.json: %w", err)
}
return m, nil
}
// readJSON is a tiny helper that decodes one of the embedded files
// into a destination value.
func readJSON(name string, dst any) error {
buf, err := rawFS.ReadFile(name)
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
}
if err := json.Unmarshal(buf, dst); err != nil {
return fmt.Errorf("decode %s: %w", name, err)
}
return nil
}

View File

@@ -0,0 +1,216 @@
package upc
import (
"time"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// SnapshotHoliday is the embedded holiday row shape. Mirrors
// paliad.holidays + the generator's output. Country and Regime are
// optional pointers — at least one of them is non-empty on every
// row (matches paliad's CHECK).
type SnapshotHoliday struct {
Date string `json:"date"` // YYYY-MM-DD
Name string `json:"name"`
Country *string `json:"country,omitempty"`
Regime *string `json:"regime,omitempty"`
State *string `json:"state,omitempty"`
HolidayType string `json:"holiday_type"`
}
func (h SnapshotHoliday) appliesTo(country, regime string) bool {
if h.Country != nil && country != "" && *h.Country == country {
return true
}
if h.Regime != nil && regime != "" && *h.Regime == regime {
return true
}
return false
}
func (h SnapshotHoliday) isVacation() bool { return h.HolidayType == "vacation" }
func (h SnapshotHoliday) isClosure() bool { return h.HolidayType == "closure" }
// SnapshotHolidayCalendar serves HolidayCalendar against the embedded
// holiday slice. The semantics mirror paliad's HolidayService:
//
// - IsNonWorkingDay = weekend OR a closure/vacation row matching
// the (country, regime) pair
// - AdjustForNonWorkingDays = walk forward day-by-day until
// IsNonWorkingDay returns false (bounded at 60 iters)
// - AdjustForNonWorkingDaysBackward = same but stepping -1 day
// - AdjustForNonWorkingDaysWithReason = forward walk + structured
// reason payload (vacation > public_holiday > weekend)
type SnapshotHolidayCalendar struct {
byDate map[string][]SnapshotHoliday // keyed by YYYY-MM-DD
}
// NewHolidayCalendar parses the embedded holidays.json and returns a
// ready-to-use calendar.
func NewHolidayCalendar() (*SnapshotHolidayCalendar, error) {
var holidays []SnapshotHoliday
if err := readJSON("holidays.json", &holidays); err != nil {
return nil, err
}
cal := &SnapshotHolidayCalendar{byDate: make(map[string][]SnapshotHoliday, len(holidays))}
for _, h := range holidays {
cal.byDate[h.Date] = append(cal.byDate[h.Date], h)
}
return cal, nil
}
// IsNonWorkingDay returns true on weekends or closure/vacation
// holidays applicable to the given country/regime.
func (c *SnapshotHolidayCalendar) IsNonWorkingDay(date time.Time, country, regime string) bool {
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
return true
}
key := date.Format("2006-01-02")
for _, h := range c.byDate[key] {
if !h.appliesTo(country, regime) {
continue
}
if h.isClosure() || h.isVacation() {
return true
}
}
return false
}
func (c *SnapshotHolidayCalendar) holidayMatch(date time.Time, country, regime string) *SnapshotHoliday {
key := date.Format("2006-01-02")
for _, h := range c.byDate[key] {
if !h.appliesTo(country, regime) {
continue
}
hh := h
return &hh
}
return nil
}
// AdjustForNonWorkingDays walks forward until the date lands on a
// working day. Bound = 60 iters (same as paliad — generous safety
// margin past any vacation run).
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// AdjustForNonWorkingDaysBackward walks backward until the date lands
// on a working day. Same bound.
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
adjusted = adjusted.AddDate(0, 0, -1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// AdjustForNonWorkingDaysWithReason is the structured-explanation
// counterpart to AdjustForNonWorkingDays. Reason kind precedence
// (longest cause wins): vacation > public_holiday > weekend. Reason
// is nil when wasAdjusted is false.
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *lp.AdjustmentReason) {
original = date
adjusted = date
var holidaysHit []lp.HolidayDTO
seen := map[string]bool{}
var sawWeekend, sawVacation, sawPublicHoliday bool
var vacationName string
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
if wd := adjusted.Weekday(); wd == time.Saturday || wd == time.Sunday {
sawWeekend = true
}
if h := c.holidayMatch(adjusted, country, regime); h != nil {
if h.isVacation() {
sawVacation = true
if vacationName == "" {
vacationName = h.Name
}
} else if h.isClosure() {
sawPublicHoliday = true
}
key := h.Date + "|" + h.Name
if !seen[key] {
holidaysHit = append(holidaysHit, lp.HolidayDTO{
Date: h.Date,
Name: h.Name,
IsVacation: h.isVacation(),
IsClosure: h.isClosure(),
})
seen[key] = true
}
}
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
if !wasAdjusted {
return adjusted, original, false, nil
}
r := &lp.AdjustmentReason{Holidays: holidaysHit}
switch {
case sawVacation:
r.Kind = "vacation"
r.VacationName = vacationName
if vs, ve, ok := c.findVacationBlock(original, country, regime); ok {
r.VacationStart = vs.Format("2006-01-02")
r.VacationEnd = ve.Format("2006-01-02")
}
case sawPublicHoliday:
r.Kind = "public_holiday"
default:
r.Kind = "weekend"
}
if sawWeekend && r.Kind == "weekend" {
r.OriginalWeekday = original.Weekday().String()
}
return adjusted, original, true, r
}
// findVacationBlock scans outward from date through non-working days
// to locate the first/last IsVacation entries. Weekends inside the
// run are traversed but don't extend the reported span — start/end
// are always real vacation entries.
func (c *SnapshotHolidayCalendar) findVacationBlock(date time.Time, country, regime string) (start, end time.Time, ok bool) {
cur := date
for i := 0; i < 60; i++ {
if !c.IsNonWorkingDay(cur, country, regime) {
break
}
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
start = cur
ok = true
break
}
cur = cur.AddDate(0, 0, -1)
}
if !ok {
return
}
cur = date
for i := 0; i < 60; i++ {
if !c.IsNonWorkingDay(cur, country, regime) {
break
}
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
end = cur
}
cur = cur.AddDate(0, 0, 1)
}
return start, end, true
}
// Compile-time assertion that SnapshotHolidayCalendar satisfies
// lp.HolidayCalendar.
var _ lp.HolidayCalendar = (*SnapshotHolidayCalendar)(nil)

View File

@@ -0,0 +1,32 @@
[
{
"date": "2026-01-01",
"name": "Neujahr",
"country": "DE",
"holiday_type": "closure"
},
{
"date": "2026-05-01",
"name": "Tag der Arbeit",
"country": "DE",
"holiday_type": "closure"
},
{
"date": "2026-08-24",
"name": "UPC Sommerpause",
"regime": "UPC",
"holiday_type": "vacation"
},
{
"date": "2026-08-25",
"name": "UPC Sommerpause",
"regime": "UPC",
"holiday_type": "vacation"
},
{
"date": "2026-08-26",
"name": "UPC Sommerpause",
"regime": "UPC",
"holiday_type": "vacation"
}
]

View File

@@ -0,0 +1,11 @@
{
"version": "2026-05-26-1-placeholder",
"generated_at": "2026-05-26T15:00:00Z",
"paliad_commit": "",
"source_db_label": "placeholder — operator must run `make snapshot-upc` against prod once mig 134/135 are applied",
"rule_count": 2,
"proceeding_count": 2,
"trigger_event_count": 0,
"holiday_count": 5,
"court_count": 2
}

View File

@@ -0,0 +1,32 @@
[
{
"id": 8,
"code": "upc.inf.cfi",
"name": "Verletzungsverfahren",
"name_en": "Infringement Action",
"description": "UPC infringement proceedings at first instance.",
"jurisdiction": "UPC",
"category": "fristenrechner",
"default_color": "#3b82f6",
"sort_order": 10,
"is_active": true,
"trigger_event_label_de": null,
"trigger_event_label_en": null,
"appeal_target": null
},
{
"id": 9,
"code": "upc.rev.cfi",
"name": "Nichtigkeitsverfahren",
"name_en": "Revocation Action",
"description": "UPC revocation proceedings at first instance.",
"jurisdiction": "UPC",
"category": "fristenrechner",
"default_color": "#f59e0b",
"sort_order": 20,
"is_active": true,
"trigger_event_label_de": null,
"trigger_event_label_en": null,
"appeal_target": null
}
]

View File

@@ -0,0 +1,43 @@
[
{
"id": "11111111-1111-1111-1111-111111111111",
"proceeding_type_id": 8,
"submission_code": "upc.inf.cfi.soc",
"name": "Klageerhebung",
"name_en": "Statement of Claim",
"duration_value": 0,
"duration_unit": "months",
"sequence_order": 1,
"is_spawn": false,
"is_active": true,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"priority": "mandatory",
"is_court_set": false,
"is_bilateral": false,
"lifecycle_state": "published"
},
{
"id": "22222222-2222-2222-2222-222222222222",
"proceeding_type_id": 8,
"parent_id": "11111111-1111-1111-1111-111111111111",
"submission_code": "upc.inf.cfi.sod",
"name": "Klageerwiderung",
"name_en": "Statement of Defence",
"primary_party": "defendant",
"duration_value": 3,
"duration_unit": "months",
"timing": "after",
"rule_code": "UPC.RoP.23.1",
"legal_source": "UPC.RoP.23.1",
"sequence_order": 2,
"is_spawn": false,
"is_active": true,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"priority": "mandatory",
"is_court_set": false,
"is_bilateral": false,
"lifecycle_state": "published"
}
]

View File

@@ -0,0 +1,301 @@
package upc
import (
"context"
"fmt"
"github.com/google/uuid"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// SnapshotCatalog is the embedded-JSON implementation of lp.Catalog.
// All lookups are O(1) on indexed in-memory maps; LookupEvents does a
// linear scan of the rule slice (< 100 rows in the UPC corpus, no
// index needed).
//
// ProjectHint is ignored — the snapshot has no project-scoped rules.
// applies_to_target (B1) and condition_expr (Phase 2) ride along on
// each Rule as ordinary fields; the engine consumes them identically
// whether the catalog is paliad-backed or snapshot-backed.
type SnapshotCatalog struct {
procs []lp.ProceedingType
rules []lp.Rule
triggerByID map[int64]lp.TriggerEvent
rulesByProc map[int][]lp.Rule
ruleByID map[uuid.UUID]lp.Rule
procByID map[int]lp.ProceedingType
procByCode map[string]lp.ProceedingType
rulesByTriggr map[int64][]lp.Rule
}
// NewCatalog parses the embedded snapshot and returns a ready-to-use
// Catalog. Returns an error when the JSON is missing or malformed
// (e.g. snapshot never generated, or stale relative to the package
// types).
func NewCatalog() (*SnapshotCatalog, error) {
var procs []lp.ProceedingType
if err := readJSON("proceeding_types.json", &procs); err != nil {
return nil, err
}
var rules []lp.Rule
if err := readJSON("rules.json", &rules); err != nil {
return nil, err
}
var triggers []lp.TriggerEvent
if err := readJSON("trigger_events.json", &triggers); err != nil {
return nil, err
}
c := &SnapshotCatalog{
procs: procs,
rules: rules,
triggerByID: make(map[int64]lp.TriggerEvent, len(triggers)),
rulesByProc: make(map[int][]lp.Rule),
ruleByID: make(map[uuid.UUID]lp.Rule, len(rules)),
procByID: make(map[int]lp.ProceedingType, len(procs)),
procByCode: make(map[string]lp.ProceedingType, len(procs)),
rulesByTriggr: make(map[int64][]lp.Rule),
}
for _, p := range procs {
c.procByID[p.ID] = p
c.procByCode[p.Code] = p
}
for _, r := range rules {
c.ruleByID[r.ID] = r
if r.ProceedingTypeID != nil {
c.rulesByProc[*r.ProceedingTypeID] = append(c.rulesByProc[*r.ProceedingTypeID], r)
}
if r.TriggerEventID != nil {
c.rulesByTriggr[*r.TriggerEventID] = append(c.rulesByTriggr[*r.TriggerEventID], r)
}
}
for _, t := range triggers {
c.triggerByID[t.ID] = t
}
return c, nil
}
// LoadProceeding returns the proceeding-type metadata + rules. The
// ProjectHint is ignored on the snapshot side (no projects).
func (c *SnapshotCatalog) LoadProceeding(_ context.Context, code string, _ lp.ProjectHint) (*lp.ProceedingType, []lp.Rule, error) {
p, ok := c.procByCode[code]
if !ok {
return nil, nil, lp.ErrUnknownProceedingType
}
// Return a defensive copy of the rule slice so callers can sort /
// mutate without leaking back into the cache.
src := c.rulesByProc[p.ID]
dst := make([]lp.Rule, len(src))
copy(dst, src)
return &p, dst, nil
}
// LoadProceedingByID is the resolver used by CalculateRule.
func (c *SnapshotCatalog) LoadProceedingByID(_ context.Context, id int) (*lp.ProceedingType, error) {
p, ok := c.procByID[id]
if !ok {
return nil, lp.ErrUnknownProceedingType
}
return &p, nil
}
// LoadRuleByID resolves a rule UUID to the rule row.
func (c *SnapshotCatalog) LoadRuleByID(_ context.Context, ruleID string) (*lp.Rule, error) {
id, err := uuid.Parse(ruleID)
if err != nil {
return nil, lp.ErrUnknownRule
}
r, ok := c.ruleByID[id]
if !ok {
return nil, lp.ErrUnknownRule
}
return &r, nil
}
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode).
func (c *SnapshotCatalog) LoadRuleByCode(_ context.Context, proceedingCode, submissionCode string) (*lp.Rule, *lp.ProceedingType, error) {
p, ok := c.procByCode[proceedingCode]
if !ok {
return nil, nil, lp.ErrUnknownProceedingType
}
for _, r := range c.rulesByProc[p.ID] {
if r.SubmissionCode != nil && *r.SubmissionCode == submissionCode {
rr := r
pp := p
return &rr, &pp, nil
}
}
return nil, nil, lp.ErrUnknownRule
}
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules.
func (c *SnapshotCatalog) LoadRulesByTriggerEvent(_ context.Context, triggerEventID int64) ([]lp.Rule, error) {
src := c.rulesByTriggr[triggerEventID]
dst := make([]lp.Rule, len(src))
copy(dst, src)
return dst, nil
}
// LoadTriggerEventsByIDs returns trigger-event rows for the given IDs.
func (c *SnapshotCatalog) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]lp.TriggerEvent, error) {
out := make(map[int64]lp.TriggerEvent, len(ids))
for _, id := range ids {
if t, ok := c.triggerByID[id]; ok {
out[id] = t
}
}
return out, nil
}
// LookupEvents runs the multi-axis filter + depth walk against the
// in-memory rule slice. Mirrors the paliad-side semantics: unknown
// axis values fall through as "no filter on this axis"; anchors are
// depth=1, walked-in children are depth=2+; results ordered by
// (proceeding_type_id, sequence_order).
func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
// Validate axes; unknown values reset to empty (no filter).
jurisdiction := axes.Jurisdiction
if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" &&
jurisdiction != "EPA" && jurisdiction != "DPMA" {
jurisdiction = ""
}
party := axes.Party
if party != "" && !lp.IsValidPrimaryParty(party) {
party = ""
}
appealTarget := axes.AppealTarget
if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) {
appealTarget = ""
}
// First pass: find anchor matches (rules that satisfy every
// non-zero axis directly).
anchors := make(map[uuid.UUID]bool, len(c.rules))
for _, r := range c.rules {
if r.ProceedingTypeID == nil {
continue
}
p := c.procByID[*r.ProceedingTypeID]
if jurisdiction != "" && (p.Jurisdiction == nil || *p.Jurisdiction != jurisdiction) {
continue
}
if axes.ProceedingTypeID != nil && *r.ProceedingTypeID != *axes.ProceedingTypeID {
continue
}
if party != "" && (r.PrimaryParty == nil || *r.PrimaryParty != party) {
continue
}
// EventCategoryID axis: the embedded snapshot doesn't carry
// the deadline_concept_event_types junction (only paliad has
// it). When EventCategoryID is set, we conservatively return
// no matches — youpc.org doesn't use this axis today. Future
// snapshot generations can add a concept→category index if
// needed.
if axes.EventCategoryID != nil {
continue
}
if appealTarget != "" {
found := false
for _, t := range r.AppliesToTarget {
if t == appealTarget {
found = true
break
}
}
if !found {
continue
}
}
anchors[r.ID] = true
}
// Second pass: depth walk. Expand anchors → their immediate
// children (parent_id ∈ matched). Iterate to fixpoint for
// EventLookupDepthAllFollowing; stop after one pass for
// EventLookupDepthNext.
matched := make(map[uuid.UUID]bool, len(anchors))
for id := range anchors {
matched[id] = true
}
if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
for {
grew := false
for _, r := range c.rules {
if matched[r.ID] {
continue
}
if r.ParentID == nil {
continue
}
if matched[*r.ParentID] {
matched[r.ID] = true
grew = true
}
}
if !grew || depth == lp.EventLookupDepthNext {
break
}
}
}
// Compute depth from anchor: walk parent_id chain until we hit
// an anchor.
depths := make(map[uuid.UUID]int, len(matched))
for id := range matched {
if anchors[id] {
depths[id] = 1
continue
}
// Walk up.
d := 1
cur := id
maxIter := len(matched) + 1
for i := 0; i < maxIter; i++ {
r, ok := c.ruleByID[cur]
if !ok || r.ParentID == nil {
break
}
d++
cur = *r.ParentID
if anchors[cur] {
break
}
}
depths[id] = d
}
// Compose output, ordered by (proceeding_type_id, sequence_order)
// via the catalog's rule slice ordering.
out := make([]lp.EventMatch, 0, len(matched))
for _, r := range c.rules {
if !matched[r.ID] {
continue
}
var parentRuleID *uuid.UUID
if r.ParentID != nil && matched[*r.ParentID] {
p := *r.ParentID
parentRuleID = &p
}
proc := lp.ProceedingType{}
if r.ProceedingTypeID != nil {
proc = c.procByID[*r.ProceedingTypeID]
}
out = append(out, lp.EventMatch{
Rule: r,
ProceedingType: proc,
Priority: r.Priority,
DepthFromAnchor: depths[r.ID],
ParentRuleID: parentRuleID,
})
}
return out, nil
}
// Compile-time assertion that SnapshotCatalog satisfies lp.Catalog.
var _ lp.Catalog = (*SnapshotCatalog)(nil)
// ErrSnapshotEmpty is returned by NewCatalog when the embedded files
// parse but the corpus is empty (zero proceedings) — almost always a
// sign that the snapshot has never been generated.
var ErrSnapshotEmpty = fmt.Errorf("upc snapshot is empty — run cmd/gen-upc-snapshot")

View File

@@ -0,0 +1,215 @@
package upc
import (
"context"
"testing"
"time"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// TestSnapshotMeta loads + parses meta.json and asserts the version
// + non-zero counts. Until the operator regenerates the snapshot the
// placeholder shipped with Slice C must still parse cleanly.
func TestSnapshotMeta(t *testing.T) {
meta, err := LoadMeta()
if err != nil {
t.Fatalf("LoadMeta: %v", err)
}
if meta.Version == "" {
t.Error("meta.Version is empty")
}
if meta.ProceedingCount <= 0 {
t.Errorf("meta.ProceedingCount = %d, want > 0", meta.ProceedingCount)
}
if meta.RuleCount <= 0 {
t.Errorf("meta.RuleCount = %d, want > 0", meta.RuleCount)
}
}
// TestSnapshotCatalog smoke-tests the embedded catalog's lookups
// against the shipped placeholder. After operator regeneration the
// asserts on per-row content still hold because they pin the wire
// shape (proceedingType.Code, rule resolution by code, lookup-events
// jurisdiction filter).
func TestSnapshotCatalog(t *testing.T) {
cat, err := NewCatalog()
if err != nil {
t.Fatalf("NewCatalog: %v", err)
}
ctx := context.Background()
t.Run("LoadProceeding upc.inf.cfi", func(t *testing.T) {
pt, rules, err := cat.LoadProceeding(ctx, "upc.inf.cfi", lp.ProjectHint{})
if err != nil {
t.Fatalf("LoadProceeding: %v", err)
}
if pt.Code != "upc.inf.cfi" {
t.Errorf("pt.Code = %q, want upc.inf.cfi", pt.Code)
}
if pt.Jurisdiction == nil || *pt.Jurisdiction != "UPC" {
t.Errorf("pt.Jurisdiction = %v, want UPC", pt.Jurisdiction)
}
if len(rules) == 0 {
t.Error("LoadProceeding returned zero rules — snapshot empty?")
}
})
t.Run("LoadProceeding unknown code returns ErrUnknownProceedingType", func(t *testing.T) {
_, _, err := cat.LoadProceeding(ctx, "no.such.code", lp.ProjectHint{})
if err != lp.ErrUnknownProceedingType {
t.Errorf("got %v, want ErrUnknownProceedingType", err)
}
})
t.Run("LookupEvents UPC all-following returns the whole UPC corpus", func(t *testing.T) {
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
if len(matches) == 0 {
t.Fatal("expected non-empty UPC corpus")
}
for _, m := range matches {
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
t.Errorf("non-UPC row leaked: %v", m.ProceedingType.Code)
}
if m.DepthFromAnchor < 1 {
t.Errorf("depth = %d, want >= 1", m.DepthFromAnchor)
}
}
})
t.Run("LookupEvents party=defendant scopes anchors", func(t *testing.T) {
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
Party: "defendant",
}, lp.EventLookupDepthNext)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
// Anchor rows (depth=1) must all be defendant.
anyDefendant := false
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue
}
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
t.Errorf("anchor row %s is not defendant: %v", m.Rule.Name, m.Rule.PrimaryParty)
}
anyDefendant = true
}
if !anyDefendant {
t.Log("no defendant rules in the placeholder corpus — operator should regenerate the snapshot")
}
})
}
// TestSnapshotEngineCompute runs the litigationplanner engine against
// the embedded snapshot end-to-end. Ensures the wiring between the
// snapshot Catalog / HolidayCalendar / CourtRegistry + the engine
// produces a non-empty timeline.
func TestSnapshotEngineCompute(t *testing.T) {
cat, err := NewCatalog()
if err != nil {
t.Fatalf("NewCatalog: %v", err)
}
hc, err := NewHolidayCalendar()
if err != nil {
t.Fatalf("NewHolidayCalendar: %v", err)
}
cr, err := NewCourtRegistry()
if err != nil {
t.Fatalf("NewCourtRegistry: %v", err)
}
ctx := context.Background()
timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-01-15", lp.CalcOptions{}, cat, hc, cr)
if err != nil {
t.Fatalf("Calculate: %v", err)
}
if timeline == nil {
t.Fatal("Calculate returned nil timeline")
}
if timeline.ProceedingType != "upc.inf.cfi" {
t.Errorf("timeline.ProceedingType = %q, want upc.inf.cfi", timeline.ProceedingType)
}
if len(timeline.Deadlines) == 0 {
t.Error("timeline has zero deadlines — snapshot empty?")
}
}
// TestSnapshotHolidayCalendar smoke-tests the embedded calendar.
// Pins core semantics: weekends are non-working; holidays at
// matching country/regime are non-working; mismatches don't fire.
func TestSnapshotHolidayCalendar(t *testing.T) {
hc, err := NewHolidayCalendar()
if err != nil {
t.Fatalf("NewHolidayCalendar: %v", err)
}
// 2026-01-03 is a Saturday — weekend, non-working regardless of
// country/regime.
sat := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
if !hc.IsNonWorkingDay(sat, "DE", "UPC") {
t.Error("Saturday should be non-working")
}
// 2026-01-01 is Neujahr (DE closure) — non-working when country=DE.
newYear := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
if !hc.IsNonWorkingDay(newYear, "DE", "UPC") {
t.Error("Neujahr should be non-working for DE")
}
// 2026-01-05 is a Monday — working (not in holidays, not weekend).
mon := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
if hc.IsNonWorkingDay(mon, "DE", "UPC") {
t.Error("Monday 2026-01-05 should be working")
}
// AdjustForNonWorkingDays from a Saturday should land on Monday.
adj, _, was := hc.AdjustForNonWorkingDays(sat, "DE", "UPC")
if !was {
t.Error("expected adjustment for Saturday")
}
if adj.Weekday() != time.Monday {
t.Errorf("adjusted weekday = %v, want Monday", adj.Weekday())
}
}
// TestSnapshotCourtRegistry pins (country, regime) resolution.
func TestSnapshotCourtRegistry(t *testing.T) {
cr, err := NewCourtRegistry()
if err != nil {
t.Fatalf("NewCourtRegistry: %v", err)
}
t.Run("empty courtID falls back to defaults", func(t *testing.T) {
c, r, err := cr.CountryRegime("", "DE", "UPC")
if err != nil {
t.Fatalf("CountryRegime: %v", err)
}
if c != "DE" || r != "UPC" {
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
}
})
t.Run("known UPC court resolves", func(t *testing.T) {
c, r, err := cr.CountryRegime("upc-ld-munich", "DE", "")
if err != nil {
t.Fatalf("CountryRegime: %v", err)
}
if c != "DE" || r != "UPC" {
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
}
})
t.Run("unknown court returns error", func(t *testing.T) {
_, _, err := cr.CountryRegime("not-a-court", "DE", "UPC")
if err == nil {
t.Error("expected error for unknown court")
}
})
}

View File

@@ -0,0 +1 @@
[]

View File

@@ -571,6 +571,21 @@ func Calculate(
if pickedProceeding.TriggerEventLabelEN != nil {
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
}
// t-paliad-301 / m/paliad#132 Bug B — appeal_target-driven trigger
// label. When the request narrows to a specific appeal target, the
// "Auslösendes Ereignis" label describes the underlying decision
// (Endentscheidung / Kostenentscheidung / Anordnung /
// Schadensbemessung / Bucheinsicht) rather than the appeal
// proceeding itself. Overrides the proceeding's own
// trigger_event_label set above.
if opts.AppealTarget != "" {
if de := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de"); de != "" {
resp.TriggerEventLabel = de
}
if en := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en"); en != "" {
resp.TriggerEventLabelEN = en
}
}
if hasSubTrackNote {
resp.ContextualNote = subTrackNote.NoteDE
resp.ContextualNoteEN = subTrackNote.NoteEN

View File

@@ -0,0 +1,68 @@
package litigationplanner
import "testing"
// TestIsValidPrimaryParty pins the four-value vocab + NULL-equivalent
// behaviour the rule-editor's B3 validation hook depends on. Empty
// string is "no value supplied" = valid (NULL maps to empty on the
// wire). Non-empty must match one of the four canonical values.
func TestIsValidPrimaryParty(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"", true},
{"claimant", true},
{"defendant", true},
{"court", true},
{"both", true},
{"Claimant", false}, // case-sensitive
{"clamant", false}, // typo
{"applicant", false}, // not in vocab
{"foo", false},
}
for _, c := range cases {
if got := IsValidPrimaryParty(c.in); got != c.want {
t.Errorf("IsValidPrimaryParty(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// TestPrimaryPartiesOrder pins the canonical chip order (admin UI
// renders these as a select; reordering would break user muscle
// memory). Update both the slice + this test together if the order
// genuinely needs to change.
func TestPrimaryPartiesOrder(t *testing.T) {
want := []string{"claimant", "defendant", "court", "both"}
if len(PrimaryParties) != len(want) {
t.Fatalf("PrimaryParties has %d entries, want %d", len(PrimaryParties), len(want))
}
for i, p := range PrimaryParties {
if p != want[i] {
t.Errorf("PrimaryParties[%d] = %q, want %q", i, p, want[i])
}
}
}
// TestIsValidAppealTarget is sibling-of: same shape, ensures the B1
// helper has the same NULL-equivalent semantic.
func TestIsValidAppealTarget(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"", true},
{"endentscheidung", true},
{"kostenentscheidung", true},
{"anordnung", true},
{"schadensbemessung", true},
{"bucheinsicht", true},
{"foo", false},
{"Endentscheidung", false}, // case-sensitive
}
for _, c := range cases {
if got := IsValidAppealTarget(c.in); got != c.want {
t.Errorf("IsValidAppealTarget(%q) = %v, want %v", c.in, got, c.want)
}
}
}

View File

@@ -185,6 +185,65 @@ type ProceedingType struct {
// — today the unified upc.apl row has this NULL (per-rule targets
// live on Rule.AppliesToTarget).
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
// Role label overrides (t-paliad-301 / m/paliad#132, mig 137).
// NULL = renderer falls back to the language-default labels
// ("Klägerseite" / "Beklagtenseite" / "Claimant side" / "Defendant side").
// Set on proceedings where the role-naming diverges from the
// claimant/defendant default (Appeal → Berufungskläger /
// Berufungsbeklagter; Revocation → Antragsteller /
// Antragsgegner Nichtigkeit; EPA Opposition → Einsprechende(r) /
// Patentinhaber(in)).
RoleProactiveLabelDE *string `db:"role_proactive_label_de" json:"role_proactive_label_de,omitempty"`
RoleProactiveLabelEN *string `db:"role_proactive_label_en" json:"role_proactive_label_en,omitempty"`
RoleReactiveLabelDE *string `db:"role_reactive_label_de" json:"role_reactive_label_de,omitempty"`
RoleReactiveLabelEN *string `db:"role_reactive_label_en" json:"role_reactive_label_en,omitempty"`
}
// TriggerEventLabelForAppealTarget returns the per-target
// "Auslösendes Ereignis" label for the unified UPC Berufung
// proceeding (t-paliad-301 / m/paliad#132 Bug B). The trigger event
// for an appeal is the underlying decision, not the appeal
// proceeding itself — these labels override the proceeding's own
// trigger_event_label when appeal_target is set.
//
// lang ∈ {"de", "en"}; any other value falls through to "de" so the
// caller never gets an empty string.
//
// Returns empty when target is empty / unknown (caller must fall
// back to the proceeding's own trigger_event_label).
func TriggerEventLabelForAppealTarget(target, lang string) string {
if lang != "en" {
lang = "de"
}
switch target {
case AppealTargetEndentscheidung:
if lang == "en" {
return "Final decision (R.118)"
}
return "Endentscheidung (R.118)"
case AppealTargetKostenentscheidung:
if lang == "en" {
return "Cost decision"
}
return "Kostenentscheidung"
case AppealTargetAnordnung:
if lang == "en" {
return "Order"
}
return "Anordnung"
case AppealTargetSchadensbemessung:
if lang == "en" {
return "Damages-assessment decision"
}
return "Entscheidung im Schadensbemessungsverfahren"
case AppealTargetBucheinsicht:
if lang == "en" {
return "Book-inspection order"
}
return "Anordnung der Bucheinsicht"
}
return ""
}
// AdjustmentReason describes why a date was rolled forward / backward
@@ -551,6 +610,50 @@ var AppealTargets = []string{
AppealTargetBucheinsicht,
}
// PrimaryParty* are the canonical four-value vocabulary for
// paliad.deadline_rules.primary_party (Slice B3, m/paliad#124 §18.3,
// mig 135). The DB CHECK constraint enforces the same set; the
// application-layer helper IsValidPrimaryParty lets the rule editor
// surface a friendly 400 before the DB error fires.
//
// NULL is also valid in the DB (for the 78 orphan cross-cutting
// concept seeds — Wiedereinsetzung, Versäumnisurteil-Einspruch,
// Schriftsatznachreichung, Weiterbehandlung). The helper treats the
// empty string as "no value supplied" = valid; non-empty strings must
// match one of the four canonical values.
const (
PrimaryPartyClaimant = "claimant"
PrimaryPartyDefendant = "defendant"
PrimaryPartyCourt = "court"
PrimaryPartyBoth = "both"
)
// PrimaryParties is the canonical ordered list for validation +
// admin-UI rendering. Order matches the rule-editor select; do not
// reorder without coordinating with the frontend.
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 to validate
// writes before they hit the DB CHECK — produces a user-friendly 400
// instead of a raw constraint-violation error.
func IsValidPrimaryParty(s string) bool {
if s == "" {
return true
}
for _, p := range PrimaryParties {
if p == s {
return true
}
}
return false
}
// IsValidAppealTarget returns true for empty (no filter requested) or
// any of the five canonical slugs. The engine uses this to gate the
// CalcOptions.AppealTarget filter — an unknown slug is silently