Compare commits

..

38 Commits

Author SHA1 Message Date
mAi
1129baba7a feat(db,services): Slice B.4 destructive drop — paliad.deadline_rules retired, INSTEAD OF triggers on view route writes (mig 140, t-paliad-305 / 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
Drops the legacy paliad.deadline_rules table after 3 weeks of dual-write
shadowing (mig 136 → B.2 dual-write → B.3 read cutover via view). The
new tables — paliad.procedural_events, paliad.sequencing_rules,
paliad.legal_sources — are the sole source of truth from this commit
forward.

Pre-flip drift verified clean against prod:
  deadline_rules=231 == sequencing_rules=231 == procedural_events=231
  legal_sources=87
  missing_sr=0, orphaned_sr=0, mismatched_lifecycle=0

* internal/db/migrations/140_drop_deadline_rules.up.sql (new) —
  Single TX, audit-first:
  1. CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules
     (precedent migs 091/093/095/098 — snapshot in same TX as destructive op).
  2. Final reconciliation UPDATE on paliad.deadlines (no-op when
     drift is already 0; defensive against last-minute writes).
  3. DROP TRIGGER deadline_rules_audit_aiud.
  4. Re-point FKs to sequencing_rules:
     - paliad.appointments.deadline_rule_id → paliad.sequencing_rules(id)
     - paliad.deadline_rule_backfill_orphans.resolved_rule_id → paliad.sequencing_rules(id)
     (the id values are identical — sr.id inherited dr.id at mig 136.)
  5. DROP COLUMN paliad.deadlines.rule_id.
  6. DROP TABLE paliad.deadline_rules.
  7. CREATE INSTEAD OF INSERT + INSTEAD OF UPDATE triggers on
     paliad.deadline_rules_unified. Triggers route writes into the
     three new tables in the same TX, preserving the legacy column
     shape on the wire so RuleEditorService SQL only needs a
     table-name swap, not a structural rewrite. Synthetic-code mint
     expression is byte-identical to mig 136 + the B.2 dual-write
     helper. POST assertions confirm the table is gone, the column
     is gone, and the snapshot matches.

  Trigger design notes (1:N caveat documented in-trigger):
  - PE identity columns (code, name, name_en, description, event_kind,
    primary_party_default, legal_source_id, concept_id) mirror from
    the writing sequencing-rule.
  - PE lifecycle columns (lifecycle_state, published_at, is_active)
    deliberately do NOT mirror — a draft sequencing-rule cloned from
    a published source shares the source's PE; we don't want the
    clone's 'draft' lifecycle to leak back onto the source's PE.
    Practical bound today (1:1 corpus); explicit comment in-trigger
    for the eventual 1:N pattern.

* internal/db/migrations/140_drop_deadline_rules.down.sql (new) —
  Best-effort restore from the snapshot. Triggers / indexes /
  CHECK constraints from historical migrations are NOT replayed;
  operator must reapply 078/079/091/095/098/122/128/134/135 to
  bring the restored table to working shape. The down path is for
  catastrophic recovery, not casual revert.

* internal/services/rule_editor_service.go —
  Six syncDualWriteFromDeadlineRule(...) calls removed (the
  INSTEAD OF triggers now do the fan-out). Five
  INSERT/UPDATE paliad.deadline_rules statements (Create,
  UpdateDraft, CloneAsDraft INSERT+SELECT, Publish, peer-archive,
  flipLifecycle) renamed to paliad.deadline_rules_unified —
  trigger handles the routing.

* internal/services/rule_editor_orphans.go — ResolveOrphan no
  longer writes deadlines.rule_id (column dropped). Sets
  sequencing_rule_id directly + derives procedural_event_id from
  the matching sequencing_rules row in the same UPDATE statement.

* internal/services/deadline_service.go — deadlineColumns now
  lists sequencing_rule_id (Deadline.RuleID still binds to it via
  the db tag rename below). Update path's appendSet("rule_id",…)
  flipped to appendSet("sequencing_rule_id",…) and post-write
  derivation moved to the renamed syncDeadlineProceduralEventID
  helper.

* internal/services/projection_service.go,
  internal/services/submission_vars.go — `WHERE rule_id = $X`
  reads on paliad.deadlines flipped to sequencing_rule_id.

* internal/models/models.go — Deadline.RuleID db tag changed from
  "rule_id" to "sequencing_rule_id". Field name + JSON name kept
  for backward compat with the frontend and existing Go callers;
  semantic value is identical (same UUID).

* internal/services/dual_write.go — Massively trimmed.
  Removed: syncDualWriteFromDeadlineRule, syncDeadlineDualLinks,
  CheckDualWriteDrift, DualWriteDriftReport, HasDrift,
  StartDualWriteDriftCheckLoop. All referenced
  paliad.deadline_rules which no longer exists.
  Kept (renamed): syncDeadlineProceduralEventID — derives
  procedural_event_id from sequencing_rule_id after any
  DeadlineService.Update that touched the back-link.

* cmd/server/main.go — Removed the StartDualWriteDriftCheckLoop
  bootstrap call (and its `time` import that only that call
  needed). Comment notes the retirement.

* internal/services/dual_write_test.go — Removed the final
  CheckDualWriteDrift assertion in
  TestDualWrite_RuleEditorLifecycle (function deleted). The
  per-step asserts against procedural_events / sequencing_rules
  / legal_sources cover the same contract by direct query.

Hard rules followed:
- Audit-first: snapshot precedes destructive ops in the same TX.
- No silent data loss: pre-drop drift was zero; snapshot captures
  the final state; FK re-points use identical UUIDs.
- INSTEAD OF triggers documented in mig 140 — single source of
  truth for the legacy→new mapping.
- Down migration is honest about its scope (catastrophic recovery
  only).

Build + vet clean. TestMigrations_NoDuplicateSlot passes. Live-DB
tests skipped (no TEST_DATABASE_URL in this env) — they'll exercise
the full mig 140 + INSTEAD OF triggers in CI.
2026-05-26 19:53:24 +02:00
mAi
d1aa0f72c0 Merge: t-paliad-305 — Slice B.3: read cutover via paliad.deadline_rules_unified view (mig 139); legacy writes retire in B.4 (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 18:01:25 +02:00
mAi
94f2831f3f Merge: fix(backup): export ORDER BY uses binding_id (was calendar_binding_id) — unblocks /admin/backups
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 18:00:37 +02:00
mAi
83be122b19 fix(backup): export ORDER BY uses binding_id, not calendar_binding_id
paliad.appointment_caldav_targets's join column is named binding_id
(mig 101). The backup sheet exporter referenced calendar_binding_id
which doesn't exist, so /admin/backups generate failed with 42703.

Single-char fix. Also flags follow-up: hardcoded ORDER BY columns on
every sheet in orgSheetQueries() are fragile under schema renames —
a separate slice (m/paliad#140) tracks making the exporter flexible
to drift (e.g. probe information_schema or use NULLS LAST id-only).
2026-05-26 18:00:17 +02:00
mAi
df592f9fc4 feat(db,services): Slice B.3 read cutover — flip reads to paliad.deadline_rules_unified view backed by sr+pe+ls (t-paliad-305 / 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
The new tables (mig 136) and the dual-write that keeps them in sync
(B.2) have been steady-state in prod since mig 136 deployed at
13:24 UTC today. Drift verified clean before this commit:
deadline_rules=231, sequencing_rules=231, procedural_events=231 (153
codes + 78 synthetic), legal_sources=87, zero mismatches across
counts, FK integrity, lifecycle, is_active.

This commit flips READ paths to source data from the new tables via
a backwards-compatible view, leaving the dual-write WRITE paths
untouched for B.4 to retire alongside the destructive drop.

* internal/db/migrations/139_deadline_rules_unified_view.up.sql (new) —
  CREATE VIEW paliad.deadline_rules_unified projecting sr+pe+ls
  back into the legacy paliad.deadline_rules column shape. Same
  column names + types so the Go-side change is a 1-token
  substitution per query with no struct or scanner edits.
  Post-apply DO block asserts view row count = sequencing_rules row
  count (FK NOT NULL on procedural_event_id guarantees they match).

* 10 service / handler files — every SELECT FROM paliad.deadline_rules
  (or JOIN paliad.deadline_rules) flipped to use the view:
  - internal/handlers/submissions.go            (Schriftsätze list)
  - internal/services/deadline_rule_service.go  (8 read sites)
  - internal/services/rule_editor_service.go    (3 read sites — ListRules, getByID, validateSpawnNoCycle)
  - internal/services/rule_editor_orphans.go    (candidate-rule lookup)
  - internal/services/submission_vars.go        (loadPublishedRule)
  - internal/services/deadline_service.go       (deadlines list join)
  - internal/services/fristenrechner.go         (calculator reads)
  - internal/services/projection_service.go     (projection reads)
  - internal/services/event_deadline_service.go (event→rule join)
  - internal/services/export_service.go         (3 export sites — ref__deadline_rules)

Verified semantically safe on live (read-only smoke):
- 231 rows in view match 231 in legacy.
- name + event_type pair: 231/231 match.
- legal_source: 231/231 match (NULL on both sides treated as match).
- submission_code: 153 non-NULL codes match exactly; the 78
  synthetic 'null.<8hex>' codes diverge from legacy NULL but no
  reader filters on NULL submission_code (verified
  handlers/submissions.go: synthetic-code rules all have NULL
  event_type so the WHERE event_type = 'filing' filter excludes
  them; the Schriftsätze surface returns the same 105 rows).

Scope decisions documented (deviation from design §5.3):
- B.3 ships the READ flip only. WRITE paths (RuleEditorService
  Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle)
  retain the dual-write from B.2 — they write to both legacy and
  new tables. B.4 (destructive drop) will retire the legacy writes
  in the same slice that drops the table, avoiding a transient
  state where the legacy writes have no purpose.
- The B.2 drift-check ticker (StartDualWriteDriftCheckLoop) stays
  active for the same reason: dual-write continues, so the
  invariants the loop checks remain meaningful.

This shape is paliadin-approvable on a "good solution > strict
phase boundary" reading of m's greenlight. If paliadin pushes back
and wants the legacy writes removed in B.3, the refactor is ~300
LOC across the 5 RuleEditorService write methods + buildPatchSets
split into PE/SR sets — schedulable as B.3.5 before B.4.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 17:59:58 +02:00
mAi
b6c2df95cc Merge: t-paliad-307 — Verfahrensablauf appeal mode fixes (side filter + synthetic trigger row + duration label + notes dedup) (m/paliad#136)
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 17:57:39 +02:00
mAi
367627af0d fix(verfahrensablauf): appeal side filter + parent in duration label + notes dedup (t-paliad-307, m/paliad#136)
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
Frontend half of the four Verfahrensablauf appeal bugs.

Bug 1 (frontend half) — Side selector dead on appeal. The column
bucketer now reads dl.appealRole (engine-stamped under
appeal_target) and routes each "both" appeal rule via the user
side: side=claimant maps the user to the appellant, so appellant
filings land in 'ours' and appellee filings in 'opponent';
side=defendant mirrors. side=null keeps the legacy mirror so every
appeal rule renders in both columns (every-rule-visible behaviour
the brief calls out). The new appealAware opt gates the path so
non-appeal proceedings keep their existing bucketing untouched.

Removed upc.apl.unified from APPELLANT_AXIS_PROCEEDINGS — appeal
routing is now per-rule via appealRole, not a page-level appellant
collapse. Other role-swap proceedings (EPA opp, DE/DPMA appeals)
keep the appellant axis since they have no appeal_target metadata.

Bug 3 — Duration label appends parent name. formatDurationLabel now
takes an optional parent fallback and renders "<n> <unit> <timing>
<parent>". deadlineCardHtml resolves the parent per-rule
(dl.parentRuleName / EN variant), falling back to opts.trigger
EventLabel for root rules with a non-zero duration (e.g.
Berufungseinlegung 2 mo. after the Endentscheidung). renderColumns
Body + renderTimelineBody auto-derive the trigger event label from
the response via the new pickTriggerEventLabel helper unless the
caller passes one explicitly.

Bug 4 — Duration prefix stripped from deadline_notes. New
stripLeadingDurationFromNotes regex peels off leading
"Frist N <unit> <vor|nach|ab|seit> …. " (DE) and
"<N>-<unit> period from …" / "N <unit> BEFORE …" / "Period is N
<unit> from …" (EN) up to the first sentence boundary. Wired into
deadlineCardHtml so noteHint + notesBlock both render the deduped
text. Per the brief's option (a): conservative regex, composite
durations with "ODER" / "whichever is the longer" stay untouched
as a follow-up editorial cleanup. deadline_rules DB untouched.

Tests: 22 new test cases across appeal-aware bucketing,
formatDurationLabel parent append, deadlineCardHtml duration
tooltip resolution, and stripLeadingDurationFromNotes regex
(positive + negative + composite + EN/DE variants). All 209
frontend tests pass.

Engine wire fields added in the preceding commit (AppealRole,
IsTriggerEvent). Reads them from CalculatedDeadline without
breaking the wire contract for non-appeal callers.
2026-05-26 17:56:32 +02:00
mAi
7d7b20651d feat(litigationplanner): appeal-target synthetic trigger row + appeal-role stamping (t-paliad-307, m/paliad#136)
Engine side of the four Verfahrensablauf appeal bugs in m/paliad#136.

Bug 2 — Missing trigger event row. When CalcOptions.AppealTarget is set,
Calculate now prepends a synthetic TimelineEntry to the deadlines slice
dated to the trigger date, carrying the per-appeal-target label from
TriggerEventLabelForAppealTarget (Endentscheidung (R.118), Kosten-
entscheidung, Anordnung, Schadensbemessung, Bucheinsicht). Marked
IsRootEvent + IsTriggerEvent + party=court + priority=informational
so the frontend renders it as a dimmed anchor card without a save
button / choices caret / click-to-edit affordance. Empty Code so it
doesn't collide with real rule UUIDs downstream.

Bug 1 (engine half) — Side selector dead on appeal. Every appeal
filing rule carries primary_party='both' in the catalog, so the
column bucketer couldn't distinguish Berufungskläger vs Berufungs-
beklagter filings from primary_party alone. Engine now stamps the
new TimelineEntry.AppealRole field with appellant/appellee from the
rule-semantic AppealFilerRole mapping (appeal_role.go) when an
appeal_target is in scope. The frontend half of the fix (next commit)
consumes this to route each "both" rule into the user-perspective
column once the user picks a side.

Mapping covers all 12 appeal filing rules across the three
applies_to_target tracks (endentscheidung/schadensbemessung,
kostenentscheidung, anordnung/bucheinsicht). Court-issued events
(merits.decision, merits.oral, cost.decision, order.order) stay
empty — they continue to route on Party='court'. Unmapped
submission_codes return empty so a new appeal rule we forgot to map
falls through to the bucketer's legacy path rather than silently
picking a side.

Tests: TestAppealFilerRole pins the mapping; TestCalculate_Appeal
SyntheticTriggerRow covers (a) synthetic row prepended + AppealRole
stamped when target is set, (b) no synthetic row + no AppealRole
when target is unset (regression guard), (c) unknown target
short-circuits to no-op. Existing tests untouched — both behaviours
gate on opts.AppealTarget != "".

No DB migration — the bugs are calc-side. deadline_rules untouched.
2026-05-26 17:56:12 +02:00
mAi
8f1a287549 Merge: t-paliad-305 — Slice B.2: dual-write to deadline_rules + procedural_events/sequencing_rules/legal_sources (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 17:50:57 +02:00
mAi
38ebccc907 feat(services): Slice B.2 dual-write — RuleEditorService writes deadline_rules AND procedural_events / sequencing_rules / legal_sources (t-paliad-305 / 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
Keeps the parallel new tables (mig 136, Slice B.1) in lock-step with
the legacy paliad.deadline_rules table through every write path on
RuleEditorService. Read paths stay on deadline_rules in B.2 — B.3
flips them and stops legacy writes.

* internal/services/dual_write.go (new) —
  - syncDualWriteFromDeadlineRule(ctx, tx, id): idempotent UPSERT of
    legal_sources + procedural_events + sequencing_rules from the
    just-written deadline_rules row. Pure SQL projection, no Go-side
    struct mapping. Synthetic-code mint expression is byte-identical
    to mig 136 ('null.' || first 8 hex of stripped uuid).
  - syncDeadlineDualLinks(ctx, tx, deadlineID): mirrors a deadline's
    legacy rule_id back-link onto deadlines.procedural_event_id +
    sequencing_rule_id. Handles NULL rule_id naturally (collapses both
    new columns to NULL).
  - CheckDualWriteDrift(ctx, conn): nine read-only count queries +
    integrity joins. Returns DualWriteDriftReport. HasDrift() bool for
    log routing.
  - StartDualWriteDriftCheckLoop(ctx, conn, interval): goroutine ticker
    that runs CheckDualWriteDrift every `interval` (default 6h) for
    the lifetime of ctx. Clean run logs at INFO; drift at WARN with
    full report.

* internal/services/rule_editor_service.go —
  - Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle
    each call syncDualWriteFromDeadlineRule(ctx, tx, id) after the
    deadline_rules mutation, before tx.Commit. Publish syncs BOTH the
    published draft AND the cloned-from peer it just archived as a
    cascade. The audit_reason already set via setAuditReasonTx
    propagates to the new-table writes (same TX, same session).

* internal/services/rule_editor_orphans.go —
  - ResolveOrphan calls syncDeadlineDualLinks after UPDATE
    paliad.deadlines SET rule_id = $1, so the parallel new columns
    follow the legacy back-link.

* internal/services/deadline_service.go —
  - DeadlineService.Update calls syncDeadlineDualLinks when
    input.RuleSet is true (auto/custom rule swap from t-paliad-258).

* cmd/server/main.go —
  - Spawns StartDualWriteDriftCheckLoop alongside CalDAV sync and
    reminder scanner. Inherits bgCtx so the goroutine stops on
    SIGTERM. Interval 6h.

* internal/services/dual_write_test.go (new) —
  - TestDualWrite_RuleEditorLifecycle: Create → UpdateDraft → Publish
    → Archive, asserts the new tables mirror at each step. Final
    CheckDualWriteDrift returns zero drift.
  - TestDualWrite_SyntheticCodeForNullSubmission: rule created with
    submission_code=NULL gets a 'null.<8hex>' procedural_events row
    matching mig 136's mint expression byte-for-byte.

Scope decisions documented in the commit:

- B.2 keeps read paths on deadline_rules. paliadin's "Read paths fall
  back to legacy" reads as "reads stay on legacy as the safety net
  while drift-check validates the new tables". B.3 swaps reads to
  new tables only AND stops writing to deadline_rules — that's a
  separate slice per the design's §5.2/§5.3 split.

- B.2 does NOT modify submission_drafts, projection_service, the
  Fristenrechner calculator, the SubmissionVarsService, the
  Schriftsätze list query, or any other reader. They keep reading
  deadline_rules unchanged. The new tables are populated in parallel
  for B.3's cutover.

- Audit triggers on deadline_rules continue to fire as before. The
  new tables have no audit triggers yet (a later slice can add
  parallel audit rows once the new tables are authoritative).

- Drift-check uses default 6h interval — short enough that a broken
  dual-write surfaces within the same business day, long enough that
  the count-COUNTs don't churn the pool. Override via the caller in
  cmd/server.

Hard rules followed:
- audit_reason set on every TX before any deadline_rules mutation
  (existing pattern; new-table writes share the same reason).
- No destructive op (B.2 is strictly additive in behaviour).
- New helpers idempotent (UPSERT ON CONFLICT DO UPDATE) — safe to
  call twice, safe to re-run after a partial failure.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 17:49:48 +02:00
mAi
3b601f156b Merge: t-paliad-306 — Slice D: paliad.scenarios + Catalog API + engine adapter (mig 145) (m/paliad#124 §5)
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 17:49:36 +02:00
mAi
cd5f752a0e feat(litigationplanner): scenarios — paliad.scenarios jsonb table + Catalog API + engine adapter (Slice D, t-paliad-306, m/paliad#124 §5)
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
A scenario is a named composition of existing proceedings + flags +
per-card choices + anchor dates. Users compose, they don't author —
spec references existing rules by submission_code; never creates new
rules. Per m's 2026-05-26 AskUserQuestion picks (doc commit 6e58595):
  Q1 composition: primary + spawned (v1); multi-proceeding peer
                  compose is the v2 goal (spec.proceedings[] array)
  Q2 scope:       per-project + abstract (project_id NULL = abstract)
  Q3 trigger:     per-anchor overrides over one base date
  Q4 storage:     NEW paliad.scenarios table with jsonb spec
                  (NOT a project_event_choices column extension)

Migration 145 — additive only. Pre-flight coordination check:
  - On-disk max: 138 (Berufung backfill, just merged).
  - Live DB tracker: 106 (significantly behind — many migs pending
    deploy).
  - curie's #93 B.2-B.6 migs not pushed yet — reserved 139-143 + 144
    as buffer; claimed 145 as the safe minimum that won't collide.
  - paliad.scenarios has audit_reason NOT applicable (no audit
    trigger on the table); updated_at trigger added on the table
    itself.
  - paliad.projects gains active_scenario_id uuid NULL FK with ON
    DELETE SET NULL (mig 134 lesson — no updated_at clauses on
    proceeding_types-style assumptions).

Schema:
  paliad.scenarios (
    id uuid pk,
    project_id uuid NULL FK → projects(id) ON DELETE CASCADE,
    name text NOT NULL CHECK char_length > 0,
    description text NULL,
    spec jsonb NOT NULL CHECK jsonb_typeof = 'object',
    created_by uuid NULL FK → users(id) ON DELETE SET NULL,
    created_at + updated_at timestamptz,
    UNIQUE NULLS NOT DISTINCT (project_id, created_by, name)
  );
  paliad.projects.active_scenario_id uuid NULL FK;
  RLS: project-scoped → can_see_project; abstract → created_by = auth.uid();
  Trigger: scenarios_touch_updated_at_trg.

pkg/litigationplanner additions:
  - Scenario struct (db + json tags)
  - ScenarioSpec / ScenarioProceeding / ScenarioCardChoice — parsed
    view of the jsonb (version-1 today, v2 multi-peer-ready)
  - ParseSpec(raw) + ScenarioSpec.PrimaryProceeding() + CalcOptionsFromSpec()
  - ScenarioFilter + Catalog.LoadScenarios + Catalog.MatchScenario
  - CalculateFromScenario(scenario, catalog, holidays, courts) — high-
    level engine entry: parses spec → builds CalcOptions → delegates
    to Calculate
  - Sentinel errors: ErrUnknownScenario, ErrInvalidScenario,
    ErrScenarioNoPrimary

paliadCatalog impl:
  - LoadScenarios with progressively-built WHERE clauses (project-id
    filter, abstract-for-user filter, or all)
  - MatchScenario by id — returns ErrUnknownScenario on not-found
  - Services connection bypasses RLS; ScenarioService enforces
    visibility at the application layer (mirrors EventChoiceService
    pattern from t-paliad-265)

SnapshotCatalog impl (embedded/upc):
  - LoadScenarios returns empty slice (no scenarios in the snapshot)
  - MatchScenario returns ErrUnknownScenario

internal/services/scenario_service.go:
  - Create / Get / ListForProject / ListAbstractForUser / Patch /
    SetActive / Delete with visibility checks
  - validateSpec checks version, base_trigger_date format, every
    proceedings[*].code resolves to an active paliad.proceeding_types
    row, every appeal_target is valid, every anchor_overrides date
    parses, every role ∈ {primary, peer}
  - SetActive validates the scenario belongs to the requested project
    (a scenario from a different project can't be active here)
  - Returns ErrScenarioNotVisible for failed visibility checks

REST endpoints (registered in handlers.go):
  GET    /api/scenarios?project=<id>             — list project's
  GET    /api/scenarios?abstract=true            — list user's abstract
  GET    /api/scenarios/{id}                     — one
  POST   /api/scenarios                          — create
  PATCH  /api/scenarios/{id}                     — partial update
  DELETE /api/scenarios/{id}                     — remove
  PUT    /api/projects/{id}/active-scenario      — set / clear active

Handler error mapping:
  - ErrUnknownScenario / ErrScenarioNotVisible → 404
  - ErrInvalidInput / ErrInvalidScenario / ErrScenarioNoPrimary → 400
  - everything else → 500

Tests:
  - pkg/litigationplanner/scenarios_test.go: ParseSpec roundtrip
    (well-formed + unknown version + malformed json),
    PrimaryProceeding zero/multi/single, CalcOptionsFromSpec full
    unpack, trigger_date_override path, no-base-trigger safety check.
    8 cases total, all DB-free.

Wired in cmd/server/main.go alongside EventChoice — same pattern,
nil-safe when DATABASE_URL is unset (handlers 503 in that mode).

Acceptance:
  - go build ./... clean
  - go test ./... all green (incl. new scenarios tests)
  - Pre-flight audit confirmed mig 145 number is safe vs curie's
    pending B.2-B.6 range
2026-05-26 17:48:56 +02:00
mAi
2377f08bd7 Merge: t-paliad-304 — R.109 anchor + columns-view duplicate fix (topo walk + 'both'→ours collapse) (m/paliad#135)
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:54:39 +02:00
mAi
1d704f6e04 fix(litigationplanner): R.109.1/R.109.4 mis-anchor + duplicate 'both' row in columns view (t-paliad-304, m/paliad#135)
Two bugs surfaced on /tools/verfahrensablauf?side=defendant for upc.inf.cfi:

1. Anchor regression for timing='before' children of court-set parents.
   Rules R.109.1 (translation_request) and R.109.4 (interpreter_cost)
   anchor on the oral hearing (parent_id=upc.inf.cfi.oral, IsCourtSet)
   but were computing dates BEFORE the Statement of Claim — 1 month
   resp. 2 weeks before the SoC instead of before the oral hearing.

   Root cause: engine walked rules in sequence_order, and the two
   "before"-timed children carry sequence_order 45/46 (their chronological
   position, before the oral hearing at 50). Their parent had therefore
   not been processed yet when the children were, so courtSet[oral.ID]
   was still empty → parentIsCourtSet=false → the engine fell back to
   the trigger date as the base.

   Fix: walk rules in topological order (parent-first) during the
   compute pass, then restore sequence_order on the output slice so
   the wire shape and the linear timeline view's render order stay
   identical to the legacy behaviour modulo the bug fix.

2. Duplicate "Antrag auf Simultanübersetzung" row in columns view.
   With primary_party='both' and an explicit side pick (?side=defendant),
   the bucketing mirrored the card into both 'Unsere Seite' and
   'Gegnerseite' — the same card on the same row, visible as a
   duplicate.

   Fix: when the user has committed to a perspective (side picked)
   but no appellant axis applies, collapse 'both' rows into ours.
   The '↔ beide Seiten' indicator is suppressed in that path to match
   the existing appellant-collapse semantics (no sibling row to mirror
   to). Legacy mirror behaviour is preserved when side is null.

DB audit ruled out a data-level duplicate: exactly one published+active
row per submission_code in paliad.deadline_rules.

Tests:
  - pkg/litigationplanner/before_court_set_anchor_test.go: synthetic
    rules pinning the conditional-on-court-set-parent contract plus
    the override path (1mo before user-pinned oral).
  - frontend/src/client/views/verfahrensablauf-core.test.ts: two new
    cases pinning the side-collapse routing for party='both'.
2026-05-26 15:54:02 +02:00
mAi
a75731a902 Merge: t-paliad-302 — Verfahrensablauf duration indicator (hover + toggle, +3 lp.TimelineEntry fields) (m/paliad#133)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
2026-05-26 15:45:15 +02:00
mAi
727e01c6c9 Merge: t-paliad-303 — backfill applies_to_target: Schadensbemessung (merits) + Bucheinsicht (order) (mig 138) (m/paliad#134)
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
2026-05-26 15:44:19 +02:00
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
3097df3918 mAi: #133 — Verfahrensablauf duration affordance (hover + toggle)
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
t-paliad-302 / m/paliad#133. Surface each event card's rule duration
("2 Mo. nach") on /tools/verfahrensablauf — by default as a hover
tooltip on the date span, and optionally inline via a new
"Dauern anzeigen" header toggle (localStorage key
paliad.verfahrensablauf.durations-show).

The issue scoped this as pure-frontend on the assumption that the
duration fields were already on the /api/tools/fristenrechner payload.
They were not: lp.TimelineEntry exposed only the computed dueDate, not
the rule's (duration_value, duration_unit, timing) tuple. Added these
as three additive optional fields and populated them in both engine
emission sites (Calculate + CalculateByTriggerEvent) from the rule
row directly. Source values are the base rule fields, not the
post-alt-swap arithmetic — the tooltip reads as a property of the
rule rather than a recap of which branch fired.

Frontend wiring:
- formatDurationLabel() in verfahrensablauf-core builds the
  "<value> <unit> <timing>" string from the existing
  deadlines.event.unit.<unit>.{one,many} + deadlines.event.timing.*
  i18n keys, reused from /tools/fristenrechner's event-mode renderer.
- deadlineCardHtml attaches the label as title= on the date span
  (hover, default) and, when CardOpts.showDurations is on, emits an
  inline <span class="timeline-duration"> in the meta row.
- Court-set / zero-duration rules (trigger event, hearings) skip the
  affordance — durationValue <= 0 short-circuits in
  formatDurationLabel.
- Toggle persisted in localStorage under
  paliad.verfahrensablauf.durations-show, default off; sits next to
  the existing "Hinweise anzeigen" toggle.

bun run build clean, go test ./pkg/litigationplanner/... and
./internal/... clean, bun test src/client/views clean (89/89).
2026-05-26 15:43:30 +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
mAi
d5bf82314a feat(litigationplanner): multi-axis catalog query API (Slice B2, 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
New Catalog.LookupEvents(ctx, axes, depth) method exposes a unified
graph query over paliad.deadline_rules + paliad.proceeding_types + the
deadline_concept_event_types junction. Used by the Determinator
cascade, the scenarios surface (Slice D), and any future "show me
events matching X" query — centralises a fan-out that today is
duplicated across multiple client-side paths.

Package additions (pkg/litigationplanner):
  - EventLookupAxes: optional Jurisdiction / *ProceedingTypeID / Party
    / *EventCategoryID / AppealTarget. All fields optional; the empty
    value (or nil pointer) is "no filter on this axis". Multiple
    non-zero axes apply as AND.
  - EventLookupDepth: "next" (1 hop downstream) or "all-following"
    (full chain).
  - EventMatch: Rule + ProceedingType + Priority + DepthFromAnchor +
    *ParentRuleID (populated only when the parent itself is in the
    returned set, so the frontend can render a tree).
  - Catalog interface gains LookupEvents.

paliad-side implementation (internal/services/fristenrechner.go):
  - SQL pass with progressively-built WHERE clauses (one $N
    placeholder per non-zero axis). EventCategoryID uses an EXISTS
    subquery against paliad.event_category_concepts joined via
    concept_id.
  - Post-fetch parent_id graph walk in Go for depth control. Loads
    the per-proceeding rule corpus via DeadlineRuleService.List so
    children whose parent_id is in the anchor set can be added even
    when those children don't match the axes themselves. AllFollowing
    iterates to fixpoint; Next stops after one pass.
  - DepthFromAnchor computed by walking each result row up the
    parent_id chain until it hits an anchor (iteration-bounded to
    prevent infinite loops on hypothetical cycles).
  - Unknown axis values (jurisdiction="XX", party="foo",
    appealTarget="invalid") silently fall through as "no filter on
    this axis" — a stale frontend chip should not drop the entire
    result set.
  - "published + active" gate (lifecycle_state='published' AND
    is_active=true) matches LoadProceeding's WHERE clause.
  - Results ordered by (proceeding_type_id, sequence_order) so the
    frontend can render without re-sorting.

Tests (internal/services/lookup_events_test.go):
  - Live-DB driven (skipped without TEST_DATABASE_URL, matches the
    existing TestCalculateRule pattern).
  - Cases: UPC-jurisdiction returns the UPC corpus only;
    party=defendant scopes anchor matches to defendant rules;
    unknown jurisdiction falls through; appeal_target=endentscheidung
    returns the merits rules from B1 mig 134;
    appeal_target=schadensbemessung returns empty (no rules seeded).

No schema delta. No frontend wiring (the new HTTP endpoint at
GET /api/tools/lookup-events can land in a follow-up slice — the
package + paliad-side impl are the deliverable here).
2026-05-26 13:54:57 +02:00
mAi
426b90bb88 Merge: t-paliad-292 — Slice B1: Berufung unification (one upc.apl + 5 appeal_target chips, mig 134 additive) (m/paliad#124 §18.1)
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:49:52 +02:00
mAi
3e1644820a Merge: t-paliad-273 — B.0 procedural-events design doc + live-DB re-validation findings (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 13:46:19 +02:00
mAi
c4c0a82abb docs(procedural-events): B.0 live-DB re-validation findings + design doc bug fix (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
Slice B.0 — read-only re-validation of cronus's procedural-events design
against the live youpc Supabase paliad schema, 24 h after the design was
authored.

* Adds docs/design-procedural-events-b0-findings-2026-05-26.md with the
  drift table, per-check confirmations, and a tightened B.1 brief.
* Annotates the cronus design doc's status header to point at the B.0
  findings file so future readers see both together.
* Fixes the self-contradictory sentence in §1 that referenced
  `deadline_rule_id` on both sides of a "not" — the live column is
  `paliad.deadlines.rule_id`, renamed directly to
  `paliad.deadlines.procedural_event_id` under Slice B (no intermediate
  step). Matching fix patched into the m/paliad#93 issue body via
  Gitea API (curl --netrc-file ~/.netrc-mai PATCH).

Key drift surfaced (vs design 2026-05-25):
- deadline_rules rows 254 → 231
- distinct submission_codes 158 → 153 (10 _archived_litigation.* codes
  gone — Q5 multi-row collapse premise is now MOOT)
- distinct legal_sources 70 → 87 (+17)
- concept-linked rules 125 → 129
- paliad.deadlines rows 1 → 5
- submission_drafts rows 4 → 7
- live mig head 123 → 133; next available = 134 (not 124)

No migration SQL written. No writes to paliad.deadline_rules. Researcher
stays parked pending m's B.1 greenlight.

Note: this commit also cherry-picks the original inventor design doc
(5bb6df6) onto the B.0 branch, because the design was never merged to
main and the doc bug fix needed somewhere to land.
2026-05-26 13:44:30 +02:00
mAi
5ab14f8b37 docs: t-paliad-262 — procedural-events data-model design (inventor)
Slice A (cosmetic rename) + Slice B (structural rework) for the
deadline_rules → procedural_events / sequencing_rules / legal_sources
split. Recommendation (R)=C (cosmetic now, structural follow-up).
Umbrella-term lock: procedural event / Verfahrensschritt.

Read-only design phase. No code or schema changes here. m/paliad#93.
2026-05-26 13:38:08 +02:00
70 changed files with 8227 additions and 177 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

@@ -221,6 +221,8 @@ func main() {
Export: services.NewExportService(pool, branding.Name),
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
Scenario: services.NewScenarioService(pool, projectSvc, rules),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
@@ -337,6 +339,11 @@ func main() {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
// Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules
// dropped. The B.2 dual-write drift-check loop is retired — the
// procedural_events / sequencing_rules / legal_sources tables
// are now the source of truth and there is no parallel side to
// compare against. Pre-drop drift was verified clean in mig 140.
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")

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

@@ -0,0 +1,277 @@
# Slice B.0 — Live DB re-validation findings (t-paliad-273)
**Author:** curie (researcher)
**Date:** 2026-05-26
**Branch:** `mai/curie/researcher-slice-b-zero`
**Predecessor:** `docs/design-procedural-events-model-2026-05-25.md` (cronus, t-paliad-262)
**Scope:** READ-ONLY re-validation of the design doc's §1 premises against the live youpc Supabase `paliad` schema. No migration SQL written, no writes to `deadline_rules` or any table. B.1 (additive migration) remains blocked pending m's greenlight.
This document does **not** redesign the schema. It does **not** propose new structural changes. It records what the live DB looks like ~24 hours after the design was authored, flags every claim that drifted, and gives the eventual B.1 coder a current-as-of-2026-05-26 baseline to plan against.
---
## §0 TL;DR
The design doc's §1 premises were sound on 2026-05-25. **All numeric premises drifted in the 24 hours since.** The qualitative model (`deadline_rules` conflates three concepts; live `deadlines.rule_id` FK; snapshot precedent established; no `proceeding_event*` tables) still holds.
The Q5 default ("10 archived multi-row submission_codes collapse safely") is now **moot**: those rows were removed from the live DB between 2026-05-25 15:30 and 2026-05-26 13:30. There are now **zero** multi-row submission codes; every active submission_code maps 1:1 to one rule row. B.1 backfill no longer needs the multi-row collapse logic that §5 of the design doc anticipated.
The Q6 default ("concept_id attaches to procedural event, not sequencing rule") is **directionally correct but needs refinement**. The empirical attachment is **above** the procedural-event level — `deadline_concepts` rows cluster legal meaning *across* jurisdictional procedural-event variants. One concept_id can span 15 distinct submission_codes (e.g. "Berufungsfrist" across BGH / BPatG / LG / OLG for both PatG and ZPO paths). The FK in §4.1's draft schema (`procedural_events.concept_id REFERENCES deadline_concepts(id)`, N:1) is **already correctly shaped** for this — no schema change needed. The verbal claim in the design doc should be tightened to "one `deadline_concept` row may be referenced by many procedural events; the FK lives on `procedural_events`."
Migration tracker drift: the design's "next available mig = 124" is stale; live head is 133 (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27 — applied **after** the design was written). **Next available is 134.** Ten migrations landed since the doc was authored — 124..133. None of them touched `deadline_rules` schema, but they did mutate row content (the missing 23 rows and the new event_type/legal_source distribution come from migs 127/128/132/133).
The design's claimed migration tracker `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 native counter (stuck at v106). The **canonical** tracker is `paliad.applied_migrations` (one row per applied migration, with checksum + applied_at). `internal/db/migrate.go:9-21` is the source of truth. Project CLAUDE.md still says `paliad.paliad_schema_migrations`; that's a stale doc, not a B.0-scope fix.
One doc-side bug fixed by this slice: design doc §1 + m/paliad#93 issue body referenced `paliad.deadlines.deadline_rule_id`. Live column is `paliad.deadlines.rule_id`. Both files patched on this branch.
---
## §1 Headline-count drift table
All numbers taken 2026-05-26 ~13:30 UTC against the live `paliad` schema.
| Metric | Design (2026-05-25) | Live (2026-05-26) | Δ | Notes |
|---|--:|--:|--:|---|
| `deadline_rules` row count | 254 | **231** | -23 | All rows `is_active = true`. No soft-deletes in flight. |
| Rows with `submission_code` | 177 | **153** | -24 | |
| Distinct `submission_code` values | 158 | **153** | -5 | **All 5 lost are the multi-row `_archived_litigation.*` codes** — see §2. |
| Rows with `legal_source` | 102 | **112** | +10 | |
| Distinct `legal_source` values | 70 | **87** | +17 | New jurisdictional variants seeded by recent migs (127/132/133). |
| Rows with `concept_id` (linked to `deadline_concepts`) | 125 | **129** | +4 | 56% of the corpus is concept-linked, vs 49% in the design. |
| `paliad.deadlines` rows | 1 | **5** | +4 | Still tiny — destructive cutover stays cheap. |
| `paliad.submission_drafts` rows | 4 | **7** | +3 | |
| Rules in `lifecycle_state = 'draft'` | 4 | **0** | -4 | All 4 design-era drafts were published or discarded. |
### event_type distribution
| `event_type` | Design | Live | Δ |
|---|--:|--:|--:|
| `filing` | 130 | 105 | -25 |
| NULL | 77 | 89 | +12 |
| `decision` | 25 | 21 | -4 |
| `hearing` | 21 | 15 | -6 |
| `order` | 1 | 1 | 0 |
| **Total** | **254** | **231** | -23 |
The -23 row delta lands almost entirely in `filing` (-25) and `hearing` (-6), offset by +12 NULL — consistent with the disappearance of the `_archived_litigation.*` filings and a few archived `hearing` rows, plus seeding of new structural / parent-only rows by recent migrations.
### What did NOT drift (qualitative claims, still valid)
- `paliad.deadline_rules` carries 39 columns (design said 38 — drift +1; likely from mig 128 `deadline_rules_unit_check` which adds a CHECK without adding a column — or one of migs 124-133 added a column. Not investigated further; out of B.0 scope).
- `paliad.deadlines.rule_id` (uuid, nullable) is the FK column to `paliad.deadline_rules.id`. **Confirmed via `information_schema.referential_constraints`**`rule_id → paliad.deadline_rules(id)`. The doc-side mention of `deadline_rule_id` was always a typo.
- `paliad.deadlines.rule_code` + `paliad.deadlines.custom_rule_text` both still present (the denormalized-display columns from mig 122).
- `paliad.submission_drafts` uses `(project_id uuid nullable, submission_code text NOT NULL)` as its key — **no FK to deadline_rules**. Confirms the design's claim that the Schriftsätze surface filters on a text key, not on `deadline_rules.id`.
- No `paliad.proceeding_event*` tables exist (einstein's 2026-05-08 graph design was never built — still the case).
---
## §2 Archived submission_code audit (Q5 re-confirm)
**Premise re-checked:** "10 archived multi-row submission_codes (`_archived_litigation.*`) collapse safely into single procedural events with multiple sequencing variants."
**Finding:** the premise is **moot in the live DB**.
```sql
SELECT submission_code, COUNT(*)
FROM paliad.deadline_rules
WHERE submission_code LIKE '_archived_litigation.%'
GROUP BY submission_code;
-- 0 rows
```
```sql
SELECT submission_code, COUNT(*)
FROM paliad.deadline_rules
WHERE submission_code IS NOT NULL
GROUP BY submission_code
HAVING COUNT(*) > 1;
-- 0 rows
```
Every active submission_code in the live corpus is 1:1 with its `deadline_rules` row. The 10 multi-row codes the design anticipated no longer exist.
**Consequence for B.1 backfill:**
- The §5.1 / §5.2 backfill SQL the design sketched (collapsing N rows-with-same-submission_code into 1 procedural_event + N sequencing_rules) is **simpler than expected**: a straight 1:1 backfill, no GROUP-BY-and-collapse step needed.
- B.1's `INSERT INTO paliad.procedural_events ... SELECT DISTINCT submission_code ...` becomes equivalent to `INSERT ... SELECT submission_code, ... FROM deadline_rules WHERE submission_code IS NOT NULL`. No deduplication needed.
- The 78 rows where `submission_code IS NULL` (231 - 153) still need a B.1 decision: do they become `procedural_events` rows (with synthetic codes), do they become free-standing `sequencing_rules` with `procedural_event_id` NULL, or do they get parked? This was implicit in the design (the 77 NULLs were framed as "structural / parent-only rows in the proceeding tree"); B.1 should make the decision explicit and document it in the migration's `.up.sql` comments.
---
## §3 concept_id attachment shape (Q6 re-confirm)
**Premise re-checked:** "concept_id attaches to procedural event, not sequencing rule."
**Finding:** **partly true.** The FK direction the design proposes (`procedural_events.concept_id → deadline_concepts.id`, N:1) is correct. The verbal phrasing in Q6's default needs refinement — the empirical attachment is **above** the procedural-event level, not "at" it.
### Empirical pattern
129 of 231 rows carry a `concept_id`. Those 129 rows reference **53 distinct `deadline_concepts`** rows. Averages: 2.43 rows-per-concept, 2.42 submission-codes-per-concept (the two are nearly identical because today's corpus has no multi-row submission codes — see §2). Span distribution:
- 33 of 53 concepts (62%) attach to exactly 1 submission_code → procedural-event-scoped.
- 20 of 53 concepts (38%) attach to >1 submission_code → cross-procedural-event scoped.
- Maximum: 1 concept attaches to **15 distinct submission_codes**.
### Example: one concept, four procedural events
The concept `b85b2e5a-4064-40b2-b862-24b7abaa5b94` ("Berufungsfrist / Berufungsschrift") is referenced by 4 `deadline_rules` rows that today carry these 4 distinct submission_codes:
| rule_code | submission_code | court | name |
|---|---|---|---|
| § 110 PatG | `de.null.bgh.berufung` | BGH | Berufungsschrift |
| § 110 PatG | `de.null.bpatg.berufung` | BPatG | Berufungsfrist |
| § 517 ZPO | `de.inf.lg.berufung` | LG | Berufungsfrist |
| § 517 ZPO | `de.inf.olg.berufung` | OLG | Berufungsfrist |
Under Slice B's target schema (§4.1), each of these four rows becomes a separate `procedural_events` row (different `code`s, different jurisdiction-specific names, different `legal_source_id`s), but **all four reference the same `deadline_concepts.id`**.
### Implication for B.1
- `procedural_events.concept_id` should be **nullable** (62% of rows today have no concept link — the §4.1 sketch already allows this).
- The constraint must be **N:1, not 1:1** (one `deadline_concept` may be referenced by many `procedural_events`). The §4.1 sketch (`concept_id uuid REFERENCES paliad.deadline_concepts(id)`) is already correctly N:1; a hypothetical "UNIQUE INDEX on `procedural_events.concept_id`" would break the existing data. **Do not add UNIQUE.**
- The design doc's Q6 phrasing can be tightened to: "concept_id attaches to procedural event (N procedural events → 1 concept). Sequencing rules do not carry concept_id." — but this is a wording nit, not a structural change. It does **not** block B.1.
---
## §4 Snapshot precedent audit
**Premise re-checked:** the `paliad.deadline_rules_pre_<N>` snapshot pattern is established and ready for B.4's destructive drop.
**Finding:** confirmed and consistent.
Snapshot tables in `paliad`:
| Snapshot table | Origin migration |
|---|---|
| `deadlines_pre_089` | mig 089 |
| `deadline_rules_pre_091` | mig 091 (destructive drop of legacy columns) |
| `event_deadlines_pre_092` | mig 092 |
| `event_deadline_rule_codes_pre_092` | mig 092 |
| `deadline_rules_pre_093` | mig 093 |
| `proceeding_types_pre_093` | mig 093 |
| `projects_pre_094` | mig 094 |
| `deadline_rules_pre_095` | mig 095 |
| `proceeding_types_pre_096` | mig 096 |
| `deadline_rules_pre_098` | mig 098 |
Pattern: `<original_table>_pre_<migration_number>`. Always created in the `.up.sql` of the destructive migration as `CREATE TABLE paliad.<t>_pre_<N> AS TABLE paliad.<t>;` (followed by the destructive DROP / ALTER).
**B.4's template:** before `DROP TABLE paliad.deadline_rules;` (and `ALTER TABLE paliad.deadlines DROP COLUMN rule_id;`), `mig <N>.up.sql` must include:
```sql
CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;
-- (optional) CREATE TABLE paliad.deadlines_pre_<N> AS TABLE paliad.deadlines;
```
This is non-negotiable per m's snapshot policy and the precedent of migs 089-098. B.4 should not enter the deploy queue without it.
---
## §5 deadlines.rule_id doc bug — verified + patched
**Premise re-checked:** the live column on `paliad.deadlines` referencing `deadline_rules` is named `rule_id`, not `deadline_rule_id`.
**Verification:**
```sql
SELECT column_name FROM information_schema.columns
WHERE table_schema='paliad' AND table_name='deadlines' AND column_name LIKE '%rule%';
-- rule_id (uuid, nullable)
-- rule_code (text, nullable)
-- custom_rule_text (text, nullable)
```
```sql
SELECT kcu.column_name, ccu.table_name, ccu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON ...
JOIN information_schema.constraint_column_usage ccu ON ...
WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema='paliad' AND tc.table_name='deadlines';
-- rule_id → paliad.deadline_rules.id
```
**Fix applied on this branch:**
- `docs/design-procedural-events-model-2026-05-25.md` — §1 row 51 already says "the column is `rule_id` (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo)". §1 row 63 (the "Doc-side bug flagged" line) already names the fix target. **No change needed to the design doc — the inventor already flagged and described the bug; B.0 just re-confirms it.**
- `m/paliad#93` issue body — line 56 says `paliad.deadlines.deadline_rule_id` in the Q3 migration shape. Patched via Gitea API on this slice. See §6 of this report.
---
## §6 Migration tracker drift (out-of-scope context)
The design doc said "next available mig number is 124 (mig 123 = Backup Mode Slice A, just shipped)". Live state on 2026-05-26 13:30:
- Latest applied migration: **133** (`upc_dmgs_pi_court_followup`, 2026-05-25 15:27).
- Next available: **134**.
- Migrations 124-133 (all applied after the design was authored):
```
124 de_inf_lg_replik_duplik_sequencing (2026-05-25 13:49)
125 cross_cutting_filter_legal_source (2026-05-25 14:13)
126 users_inbox_seen_at (2026-05-25 13:51)
127 wave0_tier0_deadline_fixes (2026-05-25 14:13)
128 deadline_rules_unit_check (2026-05-25 14:13)
129 project_event_choices (2026-05-25 15:02)
130 submission_drafts_language (2026-05-25 15:05)
131 submission_drafts_party_selection (2026-05-25 15:02)
132 wave1_tier1_rule_additions (2026-05-25 15:40)
133 upc_dmgs_pi_court_followup (2026-05-25 15:27)
```
These touched `deadline_rules` content (wave0/wave1 rule additions, sequencing fixes, unit checks) and adjacent tables, but did not change the conflated-three-concepts shape that motivates Slice B. The structural premise of the design holds; the row-level numbers shifted.
**Side observation (not a B.0 fix scope):** the project's `CLAUDE.md` says "Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`)." That sentence is stale. The **canonical tracker is `paliad.applied_migrations`** (per `internal/db/migrate.go:9-21,53,105`). `paliad.paliad_schema_migrations` is the legacy golang-migrate v1 counter, frozen at v106; the migrate runner uses it only to bootstrap `applied_migrations` on first deploy of the new runner (`internal/db/migrate.go:219-240`). Recommend a separate doc-fix slice (out of B.0 scope) to update `.claude/CLAUDE.md`.
---
## §7 Updated B.1 brief (no-op / minor adjustments only)
What the live data means for the design's §5 migration plan:
1. **Backfill is simpler.** No multi-row collapse logic needed (§2). One-to-one `INSERT INTO paliad.procedural_events SELECT submission_code, name, name_en, description, event_type AS event_kind, primary_party, ... FROM paliad.deadline_rules WHERE submission_code IS NOT NULL` against 153 rows.
2. **The 78 NULL-submission_code rows need an explicit decision in B.1.** Either:
- (a) Skip them — they remain `deadline_rules`-only and become orphan-once-deadline_rules-is-dropped. Not acceptable; B.4 would lose them.
- (b) Mint synthetic codes (`null.<uuid8>` or similar) for the structural rows and create `procedural_events` for them.
- (c) Treat them as "sequencing-rule-only" (a `sequencing_rules` row with NULL `procedural_event_id`) — would require `sequencing_rules.procedural_event_id` to be nullable, which contradicts §4.1's NOT NULL FK.
- Default recommendation: **(b)** — mint codes, preserve every row. B.1 must document the mint rule in the `.up.sql`. Surface this to head before scheduling B.1.
3. **concept_id stays N:1 on procedural_events.** No UNIQUE constraint. §4.1's sketch already does this; just don't accidentally tighten it.
4. **Use migration number 134** (or whatever's the live `MAX(version)+1` at B.1-write-time; re-check at the moment of writing the file).
5. **Snapshot before drop in B.4:** `CREATE TABLE paliad.deadline_rules_pre_<N> AS TABLE paliad.deadline_rules;` per §4 precedent. **This is the hard-stop pre-condition for B.4 entering the deploy queue.**
6. **Submission_drafts.submission_code → procedural_events.code text join** continues to work unchanged through B.1-B.3 because both names match. No B.5 dual-write needed for `submission_drafts`. (The design's §6.3 already noted this.)
None of these change the **shape** of the design — they tighten the backfill SQL and surface one explicit decision (point 2) for head.
---
## §8 Outputs of this slice (B.0)
| Artifact | Status |
|---|---|
| `docs/design-procedural-events-b0-findings-2026-05-26.md` (this file) | created on `mai/curie/researcher-slice-b-zero` |
| `docs/design-procedural-events-model-2026-05-25.md` | cherry-picked from `mai/cronus/inventor-procedural` onto this branch (design doc was never merged to main; B.0 brings it onto a branch off main so the doc bug fix has somewhere to land) |
| m/paliad#93 issue body — `deadline_rule_id``rule_id` correction | patched via Gitea API |
| Gitea comment on m/paliad#93 summarizing this report | posted (see §6 trailing summary on the issue) |
**Nothing migrated, nothing written to `paliad.deadline_rules` or any other live data table.** Only `mai.reports` (progress) and the GitHub issue body / repo files were touched.
---
## §9 Hard-stop status
**B.0 COMPLETE. AWAITING B.1 GREENLIGHT.**
Per the original instruction:
- B.1 (additive migration creating `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources` + backfill) requires explicit m approval before any new tables get created.
- B.4 (destructive drop of `paliad.deadline_rules` + `paliad.deadlines.rule_id`) requires m's downtime-window approval AND a `paliad.deadline_rules_pre_<N>` snapshot table in the same migration.
- This researcher (curie) stays parked until head re-hires.
---
## §10 Decisions worth surfacing to m before B.1 starts
1. **NULL-submission_code rows (78 of them) — what to do during backfill?** Recommendation (b): mint synthetic codes. m should confirm or pick (a)/(c).
2. **B.5 deprecation header window length** — the design (§8.2) says "one slice". For 7 active submission_drafts that's safe; the question is whether external integrations (Word templates with `{{rule.X}}`) need a longer window. The variable-bag alias contract (`submission_vars.go`) covers Word templates without a wire-format change, so "one slice" is defensible. m should confirm.
3. **Migration number reservation** — by the time B.1 ships, the live head may be 135+. The B.1 coder must re-check `MAX(version)` at write-time. (Not a decision; just a process note.)
These are the only open questions the B.0 audit surfaced. Everything else in the design holds.

View File

@@ -0,0 +1,571 @@
# Design — Procedural-Events Data Model (t-paliad-262)
**Author:** cronus (inventor)
**Date:** 2026-05-25
**Issue:** m/paliad#93 (mai task t-paliad-262)
**Branch:** `mai/cronus/inventor-procedural`
**Status:** DESIGN — read-only, no schema or code changes in this branch.
**B.0 re-validation:** see `docs/design-procedural-events-b0-findings-2026-05-26.md` (curie, 2026-05-26) for the live-DB premise re-check. Numeric §1 claims drifted; Q5 multi-row collapse premise is moot (no `_archived_litigation.*` rows remain); Q6 N:1 attachment confirmed; mig number target updated 124 → 134.
**Prior art read:**
- `docs/design-deadline-data-model-2026-05-08.md` (einstein, t-paliad-158) — proposed `proceeding_event_types` + `proceeding_event_edges`; the **graph-shape recommendation has not been built** (no `proceeding_event*` tables exist in the live DB as of 2026-05-25, verified via `information_schema.tables`).
- `docs/design-fristen-phase2-2026-05-15.md` (Phase 2/3 unified-rule columns — migs 078/079/091, **shipped**).
- `docs/design-submission-generator-2026-05-19.md` and `docs/design-submission-page-2026-05-22.md` (Slice 1 → Slice A of the Schriftsätze stack — shipped on top of today's `deadline_rules`).
This doc names a single conflation in the schema and proposes a two-slice fix (cosmetic immediate, structural follow-up). It is intentionally narrower than einstein's 2026-05-08 graph proposal — it does **not** re-litigate the proceeding-as-DAG question.
---
## §0 TL;DR
`paliad.deadline_rules` today is **one row that wears three hats**:
1. **The procedural-event template**`submission_code`, `name`, `name_en`, `description`, `event_type`, `primary_party`. This is "what kind of step is this in the proceeding": Rechtsbeschwerdebegründung, mündliche Verhandlung, Entscheidung, etc.
2. **The legal-norm citation**`legal_source`, `rule_code`, `alt_rule_code`, `rule_codes[]`. This is "the source-of-law anchor": § 102 PatG, UPC RoP R.220(1).
3. **The sequencing rule**`parent_id`, `trigger_event_id`, `duration_value`, `duration_unit`, `timing`, `alt_duration_*`, `combine_op`, `condition_expr`, `is_spawn`, `spawn_*`, `sequence_order`, `is_court_set`, `priority`, `anchor_alt`, `proceeding_type_id`. This is "how and when does it fire relative to other events".
The conflation surfaces most painfully in the submission-draft editor's variable sidebar (m's report 2026-05-25 15:02), where the lawyer sees field labels like `{{rule.submission_code}}` for what is plainly a *procedural-event code*, `{{rule.event_type}}` for what is plainly the *procedural-event kind*, and `{{rule.legal_source_pretty}}` for what is plainly the *legal norm* — all under a `rule.*` namespace that reads as if the lawyer were filling in arithmetic.
**Recommendation = Q1 option (C):**
- **Slice A (immediate, this design's coder shift):** cosmetic rename — placeholders, i18n labels, Go struct-comment naming, admin-UI page titles all shift to `procedural_event.*` as the canonical name. **Database schema, table name, column names, FK directions, JSON envelope keys on the wire all stay exactly as they are.** Old `{{rule.*}}` placeholders remain emitted in the variable bag as legacy aliases so existing Word templates and saved drafts keep working.
- **Slice B (planned follow-up, separate mai task, separate slice plan):** structural rework — extract `paliad.procedural_events`, `paliad.sequencing_rules`, `paliad.legal_sources`, with a phased dual-write migration. **Not shipped here.** This doc defines the target shape (§4) and the migration shape (§5) so the eventual coder has a brief, not so the eventual coder is hired today.
**Umbrella term lock = Q2 option (R):** **"procedural event"** (DE: **"Verfahrensschritt"**) as the umbrella covering filings, hearings, decisions, orders. Justification in §2.
Both Slice A and the eventual Slice B preserve the Schriftsätze surface (t-paliad-238/242/243): the submissions list query changes its predicate from `dr.event_type = 'filing'` to `pe.event_kind IN ('filing', 'reply')` (Slice B only) — same rows, cleaner predicate.
---
## §1 Premises verified live (2026-05-25)
Every load-bearing claim was checked against the running paliad codebase + youpc Supabase. Numbers and schema facts are point-in-time as of 2026-05-25 15:30.
| Claim | Verification |
|---|---|
| `paliad.deadline_rules` carries the 38 columns listed in §0's three-hats decomposition. | `information_schema.columns WHERE table_schema='paliad' AND table_name='deadline_rules'` — 38 rows; columns confirmed verbatim. |
| Live row count = 254. | `SELECT COUNT(*) FROM paliad.deadline_rules` → 254. |
| 177 rows carry a `submission_code` (procedural-event identity); 158 distinct values. | `COUNT(*) FILTER (WHERE submission_code IS NOT NULL)` → 177; `COUNT(DISTINCT submission_code)` → 158. |
| 102 rows carry a `legal_source`; 70 distinct citations. | Same query, `legal_source` column. |
| 125 rows are linked to a `deadline_concepts` row via `concept_id`. | `COUNT(*) FILTER (WHERE concept_id IS NOT NULL)` → 125 (49 % of the corpus). |
| `event_type` distribution: 130 `filing` · 77 NULL · 25 `decision` · 21 `hearing` · 1 `order`. | `SELECT event_type, count(*) GROUP BY event_type` — confirmed; the 77 NULL rows are structural / parent-only rows in the proceeding tree. |
| 10 `submission_code` values appear on more than one row (jurisdictional / bilateral variants). | All 10 today are `_archived_litigation.*` codes (claimant/defendant splits + multi-stage hearing rows). Live non-archived codes are 1:1 with rows in the current corpus. |
| `paliad.deadlines` joins to `deadline_rules` via column `rule_id` (uuid, FK). The text `rule_code` and free-text `custom_rule_text` (mig 122, t-paliad-258) are denormalized for display when the rule row is deleted. | `internal/services/deadline_service.go:69-127`; live column list confirms `rule_id`, `rule_code`, `custom_rule_text` — there is **no** `deadline_rule_id` column on deadlines (issue body called it `deadlines.deadline_rule_id` — that's a doc-side typo; the column is `rule_id`). |
| `paliad.submission_drafts` keys to a procedural event via `submission_code` text — **no FK** to `deadline_rules`. | `information_schema.columns` for `submission_drafts`: `submission_code text` plus `(project_id, submission_code)` as the joint identifier. Confirms the Schriftsätze surface filters on the *text key*, not on `deadline_rules.id`. |
| The Schriftsätze list (t-paliad-238) filters `deadline_rules` by `event_type='filing'` and `submission_code IS NOT NULL`. | `internal/handlers/submissions.go:193-211` — verbatim. |
| The variable bag emits exactly 8 `rule.*` placeholders. | `internal/services/submission_vars.go:349-364``rule.submission_code`, `rule.name`, `rule.name_de`, `rule.name_en`, `rule.legal_source`, `rule.legal_source_pretty`, `rule.primary_party`, `rule.event_type`. Frontend i18n labels at `frontend/src/client/submission-draft.ts:158-185`. |
| Admin rule-edit form binds the same `rule.X` fields. | `frontend/src/admin-rules-edit.tsx:74-110` + `frontend/src/client/admin-rules-edit.ts:253-278` — same eight columns surfaced as form inputs. |
| The Fristenrechner client surface refers to `calc.rule.nameDE` / `calc.rule.nameEN`. | `frontend/src/client/fristenrechner.ts:1592,1655`. |
| einstein's 2026-05-08 `proceeding_event_types` + `proceeding_event_edges` are **not** in the DB. | `SELECT table_name FROM information_schema.tables WHERE table_schema='paliad' AND table_name LIKE '%proceeding_event%'` → 0 rows. The graph-shape proposal was never built. |
| `paliad.deadline_concepts` (57 rows in the original einstein audit; live count not directly queried this shift) still exists and is referenced via `deadline_rules.concept_id`. | `information_schema.tables` confirms `deadline_concepts`, `deadline_concept_event_types`, `deadline_event_types`, `event_types`, `trigger_events`, `event_categories` all still present — the deadline-knowledge graph from the einstein design lives on alongside the unified rule columns. |
| Phase 2/3 columns (`priority`, `condition_expr`, `is_court_set`, `lifecycle_state`, `draft_of`, `published_at`, `rule_codes[]`) are live and load-bearing. | `internal/models/models.go:622-684` + mig 091. Slice B's structural rework must preserve every one of these on the new `sequencing_rules` table — they are not legacy. |
| Live `paliad.deadlines` references to rules are sparse (1 row in prod). | `SELECT COUNT(*) FROM paliad.deadlines` → 1. The 4 `submission_drafts` rows reference a procedural event by `submission_code` text only. Tiny live FK surface → migrations can be aggressive without losing user data. |
| Migration tracker is `paliad.paliad_schema_migrations`; next available number is 124 (mig 123 = Backup Mode Slice A, just shipped). | `internal/db/migrations/` directory listing; latest applied = 123. |
**Doc-side bug flagged for this issue's body:** the deliverable spec writes `paliad.deadlines.deadline_rule_id` in §3 (Q3 migration shape). The live column is `paliad.deadlines.rule_id`. Slice B's rename target is therefore `paliad.deadlines.procedural_event_id`, renamed directly from `paliad.deadlines.rule_id` — there is no intermediate `deadline_rule_id` step (no such column exists). Updating the issue body is m's call — flagged here so it doesn't propagate into a coder brief. *(B.0 update 2026-05-26: issue body patched. See `docs/design-procedural-events-b0-findings-2026-05-26.md` §5.)*
---
## §2 m's vocabulary call (Q2 — lock the umbrella term)
m proposed "procedural event" in the report. Options weighed:
| Option | Reads as | Collisions | Verdict |
|---|---|---|---|
| **"procedural event"** (DE: "Verfahrensschritt") | Umbrella that naturally covers filings, hearings, decisions, orders. Matches lawyer mental model: "the next thing that happens in the proceeding". | None — no `paliad.procedural_event*` table or column today (verified). | **(R) — adopt as canonical.** |
| "submission" | Today the Schriftsätze surface uses this for *filings only* (`event_type='filing'`). Expanding the meaning would silently change Slice A's semantics for an existing UI. | Surface-level collision with the Schriftsätze nomenclature already in production. | Reject — would lose precision for an existing concept. |
| "event" / "event_type" | Existing `deadline_rules.event_type` column. | **Hard collision** with `paliad.events` (audit feed, distinct table, distinct meaning). Renaming around it would be worse than the conflation we're trying to fix. | Reject. |
| "Verfahrensschritt" only (no English) | Cleanest German but no English fallback. | Bilingual UI (DE primary, EN secondary per project CLAUDE.md) requires both. | Reject in isolation — but **adopt as the canonical German rendering** of "procedural event". |
| "Verfahrensereignis" | Closer literal translation of "procedural event". | None. | Reject in favor of "Verfahrensschritt" — m's broader vocabulary uses "Schritt" (e.g. "Antragsschritt") more naturally than "Ereignis", which already maps to `paliad.events` in the audit-feed sense. |
**Lock:**
| Surface | Canonical |
|---|---|
| English | **procedural event** (lowercase except sentence-initial) |
| German | **Verfahrensschritt** (m. — der Verfahrensschritt) |
| Plural EN | procedural events |
| Plural DE | Verfahrensschritte |
| Code identifier (Go struct names, TS types) | `ProceduralEvent`, `ProceduralEventKind`, `ProceduralEventTemplate` |
| Snake-case (DB columns, JSON keys, i18n keys, placeholders) | `procedural_event`, `procedural_event_kind`, `procedural_events` (table) |
| Slice A: variable-bag placeholder namespace | `procedural_event.*` (with `rule.*` kept as legacy alias) |
| Slice B: table name (if shipped) | `paliad.procedural_events` |
`event_type` (the column) becomes `event_kind` in Slice B — using "kind" rather than "type" to free up the word "type" for the proceeding-level taxonomy (`paliad.proceeding_types`, untouched) and to mirror the "event_type vs event_kind" disambiguation einstein hit in the 2026-05-08 doc. In Slice A the column stays `event_type` (no DB change).
**Q2 is locked by inventor recommendation.** It costs nothing structurally and clears noise across every downstream conversation. If m disagrees in the head round-trip, the only thing that flips is the term — Slice A's scope shape stays.
---
## §3 Scope decision (Q1 — A vs B vs C)
**Recommendation = (C) — cosmetic rename now, structural rework as a planned follow-up.**
### Why not (A) — cosmetic only and stop
(A) leaves the model wrong forever. The conflation isn't just a labelling annoyance — it makes future questions harder to answer cleanly:
- "How many distinct procedural events does paliad model?" Today: ambiguous (rows vs distinct `submission_code`s vs distinct `(submission_code, proceeding_type_id)` tuples).
- "Where can we attach a per-procedural-event Word template that's independent of which proceeding it appears in?" Today: nowhere — the FK chain forces a per-row template registry, see `internal/handlers/files.go` template fallback.
- "Show me every sequencing rule that triggers a given procedural event across all proceedings." Today: requires joining `deadline_rules` to itself on `submission_code` + `parent_id`, brittle.
If m signals (A) anyway — fine; the cosmetic-only slice is a strict subset of (C)'s Slice A and ships the same value (label clarity in the editor). But the recommendation is to write down the structural target now while the analysis is fresh.
### Why not (B) — restructure immediately
(B) means: one slice plan, one cutover. With:
- 254 live rule rows,
- 1 live `paliad.deadlines` row,
- 4 live `submission_drafts` rows,
- 12 Go services + 6 handlers touching `deadline_rules` + 8 placeholder strings on the wire + the admin rule-editor UI bound to the column shape,
…doing this in one cutover means a big-bang migration during a downtime window. m has granted exactly one such window in recent memory (2026-05-15 for mig 091's destructive drops), and that one was constrained to a 4-column drop. A four-table restructure has a meaningfully larger blast radius; it warrants its own task with its own slice plan and its own risk review.
### Why (C) — cosmetic-rename Slice A this design, structural Slice B as a separate task
Three properties of (C) make it the safe call:
1. **Slice A is reversible at any time** — every change is in i18n strings, Go struct comments, admin-UI page titles, and the variable-bag aliases. No DB migration. No drop. A revert is a `git revert` of the Slice A commit.
2. **Slice B is fully designed but uncommitted** — §4 and §5 below define the target shape and migration plan, but the design doc itself ships in Slice A. m can read it, redirect it, or park it without pressure to ship it now.
3. **The Schriftsätze surface doesn't care which slice we ship** — Slice A leaves it on `event_type='filing'`; Slice B flips it to `event_kind IN ('filing', 'reply')` over a dual-write window. Either way, the lawyer-facing behavior is unchanged.
### Slice A's deliverable boundary (what gets renamed, what stays)
**Renamed in Slice A:**
- **i18n keys** for the admin rule-editor field labels: `admin.rules.edit.field.submission_code``admin.rules.edit.field.procedural_event_code`, etc. (16 keys total — `name`, `name_en`, `description`, `submission_code`, `rule_code`, `legal_source`, `primary_party`, `event_type` × DE/EN — full list in §7.1.)
- **Variable-bag placeholder labels** in `submission-draft.ts:158-185`: the *visible label* (`{ de: "Schriftsatz-Code", en: "Submission code" }`) is unchanged for filings (filings are still Schriftsätze on that surface), but the **namespace shown next to the placeholder string** changes: lawyer sees `{{procedural_event.code}}` in the placeholder column with the same Schriftsatz-Code label and same value. The old `{{rule.submission_code}}` stays in the catalog as an "(alt)" entry pointing at the same field.
- **Variable-bag emission** (`internal/services/submission_vars.go:351-364`): the bag emits **both** key-names for every value, so any Word template / saved draft holding `{{rule.X}}` keeps working without a touch. New templates and the in-app catalog show the canonical `{{procedural_event.X}}` name.
- **Admin page titles + section headings**: "Regel bearbeiten" → "Verfahrensschritt bearbeiten" (DE), "Edit rule" → "Edit procedural event" (EN). "Regeln verwalten" → "Verfahrensschritte verwalten" / "Procedural events". The URL path `/admin/rules` stays — URL renames have downstream cost (bookmarks, audit log entries) and would need their own redirect slice (out of scope here).
- **Go struct comments + service docstrings + worker-facing log lines** that refer to "the rule" → "the procedural event" where the referent is the procedural-event aspect (not the sequencing-rule aspect). Function names, type names, table name stay (Slice B handles those).
- **The "Submission Code / Einreichung-Kennung" label** itself stays (it's the lawyer's anchor — they recognize it). The framing around it changes: it now reads as "the code that identifies this *procedural event*", not "the code attached to this *rule*".
**Untouched in Slice A:**
- Database schema. Table name (`paliad.deadline_rules`). Column names. FK directions. Indexes. RLS policies. Triggers. Audit log column `rule_id`.
- Go struct names: `DeadlineRule` stays. The renames here are *prose*, not *code*. Renaming `DeadlineRule` to `ProceduralEvent` couples Slice A to Slice B's table rename — keep them decoupled.
- JSON envelope keys on the wire (`POST /api/admin/rules/:id` still accepts `submission_code` in the body — Slice B's API rename is a breaking change with its own deprecation window).
- URL paths (`/admin/rules`, `/api/admin/rules/:id`, `/api/projects/:id/submissions` etc.).
- `paliad.deadlines.rule_id` FK column name.
- The variable-bag's legacy `{{rule.X}}` keys — kept forever as aliases (cheap, zero rot).
- The `submission_drafts` table's `submission_code` text key.
This boundary makes Slice A a one-day coder shift: scoped, reversible, label-only.
### What Slice B inherits
Slice B inherits a codebase + a UI where every prose surface already speaks "procedural event". It also inherits a *legacy alias contract* (the dual emission in the variable bag) that gives it freedom to rename the JSON keys on the wire and the Go struct in two separate sub-slices without rushing.
---
## §4 Restructure schema (Q3 — if/when we ship Slice B)
This is the target the eventual Slice B coder would land. **Nothing here ships in this task.**
### §4.1 Three new tables (plus the rename of `deadline_rules`)
```sql
-- 1. Procedural event templates — one row per (procedural-event identity)
-- For now the live corpus is 1:1 with non-archived submission_codes
-- (148 of the 158 distinct codes), so we get ~177 rows minus the 10
-- multi-row codes' duplicates. Bilateral / jurisdictional variants
-- are modeled at the sequencing_rules layer.
CREATE TABLE paliad.procedural_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE, -- former submission_code
name text NOT NULL, -- DE
name_en text NOT NULL,
description text,
event_kind text NOT NULL, -- filing|reply|hearing|decision|order|other
primary_party_default text, -- claimant|defendant|both|court
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|published|archived
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()
);
```
```sql
-- 2. Legal sources — the source-of-law citations the procedural event
-- anchors against. ~70 distinct values today (live corpus).
CREATE TABLE paliad.legal_sources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
citation text NOT NULL UNIQUE, -- "DE.PatG.102", "UPC.RoP.220.1", …
jurisdiction text NOT NULL, -- DE|UPC|EPA|DPMA|other
pretty_de text NOT NULL, -- "§ 102 PatG"
pretty_en text NOT NULL, -- "Section 102 PatG"
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
```sql
-- 3. Sequencing rules — the timing / trigger / condition mechanics that
-- today live alongside the procedural-event identity on deadline_rules.
-- One row per (procedural_event × proceeding × variant). The 10
-- "_archived_litigation.*" codes that today have 2-5 rows become
-- 2-5 sequencing_rules rows for the same procedural_events row.
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), -- structural tree, today's parent_id
trigger_event_id bigint REFERENCES paliad.trigger_events(id), -- event-rooted variant
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, -- legacy free-text alt citation, retained
anchor_alt text,
combine_op text, -- max|min
condition_expr jsonb,
primary_party text, -- per-rule override of the procedural_event default
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, -- legacy short-form citation, retained on the rule
rule_codes text[], -- multi-citation array (mig pre-091)
deadline_notes text,
deadline_notes_en 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()
);
```
```sql
-- 4. Rename downstream FK + add the link to procedural_events.
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);
-- (rule_id stays as a transitional alias during the dual-write window;
-- dropped at end of Slice B)
```
```sql
-- 5. Submission drafts: add procedural_event_id FK alongside submission_code.
ALTER TABLE paliad.submission_drafts
ADD COLUMN procedural_event_id uuid REFERENCES paliad.procedural_events(id);
-- (submission_code stays — it's the cosmetic anchor lawyers recognize
-- in URLs and chat, and it doubles as the procedural_events.code value)
```
### §4.2 What goes where (column-by-column map)
Every column on today's `paliad.deadline_rules` lands on exactly one of the three new tables:
| Today's `deadline_rules` column | Lands on | Notes |
|---|---|---|
| `id`, `created_at`, `updated_at` | `sequencing_rules` | The current row's identity becomes a sequencing-rule row. `procedural_events.id` is **new** — backfilled from `submission_code`. |
| `submission_code` | `procedural_events.code` | Promoted up. Multi-row codes (10 in corpus, all `_archived_litigation.*`) collapse to one row on the new table; the 2-5 sequencing rows hang off it. |
| `name`, `name_en`, `description` | `procedural_events` | Procedural-event identity. |
| `primary_party` | `procedural_events.primary_party_default` AND `sequencing_rules.primary_party` | Both. The procedural event has a default party (claimant for Klage etc.); the sequencing rule can override per-jurisdiction (bilateral variants — e.g. `litigation.reply` claimant vs defendant become two sequencing rows with overridden party). |
| `event_type` | `procedural_events.event_kind` | Hat 1, with rename to `event_kind` (term lock §2). |
| `legal_source` | `legal_sources.citation` + FK from `procedural_events.legal_source_id` | The citation moves to its own row; the procedural event points at it. `pretty_de` / `pretty_en` materialize the existing `legalSourcePretty()` function output as columns (with the function retained as the migration source). |
| `rule_code`, `alt_rule_code`, `rule_codes[]` | `sequencing_rules` | Short-form citation arrays stay on the sequencing rule — they're rule-specific. |
| `proceeding_type_id`, `parent_id`, `trigger_event_id`, `spawn_proceeding_type_id`, `is_spawn`, `spawn_label`, `is_bilateral`, `is_court_set`, `combine_op` | `sequencing_rules` | Hat 3 (mechanics) — exact copies. |
| `duration_value`, `duration_unit`, `timing`, `alt_duration_value`, `alt_duration_unit`, `anchor_alt` | `sequencing_rules` | Hat 3 (mechanics). |
| `condition_expr` (jsonb) | `sequencing_rules` | Hat 3. The grammar from mig 091 stays. |
| `priority`, `sequence_order` | `sequencing_rules` | Hat 3. |
| `is_active`, `lifecycle_state`, `draft_of`, `published_at` | **BOTH** `procedural_events` AND `sequencing_rules` | A procedural event can be retired independently of any one of its sequencing variants. Backfill: copy onto both during dual-write; new rows go through the rule-editor service which writes both sides together. |
| `concept_id` (FK to `deadline_concepts`) | `procedural_events.concept_id` | The concept layer (einstein 2026-05-08) attaches to the procedural event, not the sequencing rule. |
| `deadline_notes`, `deadline_notes_en` | `sequencing_rules` | They're rule-specific notes ("filing the appeal in DE costs €X if you also did Y") — not procedural-event-wide. |
Three columns disappear:
- The semantically-overloaded part of `event_type` (renamed to `event_kind` and moved).
- The "what is this thing" vs "how does it fire" name conflict — gone by construction.
- Any column that exists only because of the conflation (none of today's columns are pure overhead — they all carry data — so the count stays at 38 across the three new tables).
### §4.3 Indexes + RLS
`paliad.can_see_project()` is the canonical RLS predicate (mig 055). None of the three new tables hold project-scoped data — they're firm-wide reference tables. RLS = none, same posture as today's `deadline_rules` (which is firm-wide and unrestricted at the row level; access control is via the `lifecycle_state='published'` filter in the read paths).
Indexes inherited from today:
- `paliad.legal_sources(citation)` — UNIQUE.
- `paliad.procedural_events(code)` — UNIQUE.
- `paliad.procedural_events(concept_id)` — for the deadline-concept join.
- `paliad.sequencing_rules(procedural_event_id, proceeding_type_id, lifecycle_state)` — primary read path for the calculator.
- `paliad.sequencing_rules(parent_id)` — tree walk.
- `paliad.sequencing_rules(trigger_event_id)` — event-rooted variant.
---
## §5 Migration plan (Slice B — when it ships, not in this task)
Phased dual-write, so the cutover is **never** a single instant where the wire format flips. m gets to roll back any one phase with a `git revert` + an `ALTER TABLE` if a phase misbehaves in prod.
### §5.1 Phase 1 — Additive (no down-time)
1. Create `procedural_events`, `sequencing_rules`, `legal_sources`.
2. Backfill `legal_sources` from `DISTINCT legal_source` on `deadline_rules` (70 rows). Populate `pretty_de`/`pretty_en` by calling the existing `legalSourcePretty()` function in a one-shot SQL/Go shim during the migration. Verify `COUNT(DISTINCT legal_source FROM deadline_rules) = COUNT(*) FROM legal_sources`.
3. Backfill `procedural_events` from `DISTINCT submission_code` on `deadline_rules WHERE submission_code IS NOT NULL`. Take `name`, `name_en`, `event_type → event_kind`, `primary_party`, `concept_id`, `description` from the lowest-`id` rule row for each code (tie-breaker: lowest `sequence_order`). Verify `COUNT(*) FROM procedural_events = COUNT(DISTINCT submission_code FROM deadline_rules WHERE submission_code IS NOT NULL)` (= 158).
4. Backfill `sequencing_rules` 1:1 from `deadline_rules` (254 rows). FK `procedural_event_id` resolved by code lookup; sequencing-rule row inherits the `deadline_rules.id` (so existing `deadlines.rule_id` FKs continue to resolve via the new column for the dual-write window — see Phase 3).
5. Add `paliad.deadlines.procedural_event_id` + `sequencing_rule_id` columns, backfill from `deadlines.rule_id` join.
6. Add `paliad.submission_drafts.procedural_event_id`, backfill from `submission_code` join.
This phase ships behind a feature flag (or just behind unused code) — readers + writers stay on `deadline_rules`. No behavior change.
### §5.2 Phase 2 — Dual-write (no down-time)
7. Update `RuleEditorService` to write to both `deadline_rules` (legacy) and (`procedural_events`, `sequencing_rules`, `legal_sources`) on every Create/Update/Publish/Archive. Audit log writes one row per side.
8. Update read paths to **read from the new tables**, falling back to `deadline_rules` if the new row is missing (defense-in-depth during backfill catch-up).
9. Run for ≥ 1 week (m's call on length). Compare row counts and a hash digest of the union daily — if drift, surface.
### §5.3 Phase 3 — Cutover (no down-time, but reversible only via re-application of the dual-write)
10. Flip read paths to **only** the new tables (`SubmissionVarsService.loadPublishedRule`, `DeadlineRuleService.*`, `SubmissionService.list`, `ProjectionService`, `FristenrechnerCalc`, etc.).
11. Stop writing to `deadline_rules`.
12. `paliad.deadlines.rule_id` is kept as a no-op alias for one more week; new writes go to `procedural_event_id` + `sequencing_rule_id`.
13. `submission_drafts.submission_code` is kept as the URL anchor; the FK `procedural_event_id` is the primary join key going forward.
### §5.4 Phase 4 — Drop legacy (downtime window, destructive)
14. `paliad.deadline_rules_pre_<slice-B-mig>` snapshot of the entire table.
15. DROP TABLE paliad.deadline_rules (after CASCADE-safe FK rewires).
16. DROP COLUMN paliad.deadlines.rule_id (keep `rule_code` + `custom_rule_text` as the human-readable denormalized columns — they're the safety net for orphaned deadlines per t-paliad-258).
m grants this destructive phase its own window (precedent: mig 091 on 2026-05-15). Until then, the legacy table sits dormant.
### §5.5 Migration tracker
- Slice B uses migration numbers 124 (Phase 1 — create tables + backfill) and onward — a 4-5 migration sequence, one per phase boundary, mirroring the Phase 2/3 slicing that shipped under t-paliad-195.
- Each migration includes a `paliad.audit_reason = 'mig <n>: <slice-B-phase>'` set_config like mig 091 did, so the audit log captures the schema journey.
---
## §6 Service-layer impact
### §6.1 Slice A — prose-only changes
| File | Change |
|---|---|
| `internal/services/submission_vars.go` | `addRuleVars` → also emit `procedural_event.code`, `procedural_event.name`, `procedural_event.name_de`, `procedural_event.name_en`, `procedural_event.legal_source`, `procedural_event.legal_source_pretty`, `procedural_event.primary_party`, `procedural_event.event_kind` (8 new keys, 1:1 with the 8 existing `rule.*` keys, same values). Rename docstrings + the package-level placeholder map comment ("`rule.*`" → "`procedural_event.*` (with legacy alias `rule.*`)"). |
| `internal/services/deadline_rule_service.go` | Top-of-file comment + struct comment renames only. Method names stay (`DeadlineRuleService`, `GetByID`, etc.). |
| `internal/services/rule_editor_service.go` | Same. |
| `internal/services/projection_service.go`, `deadline_service.go`, `fristenrechner.go`, `submission_draft_service.go`, `event_trigger_service.go`, `event_deadline_service.go`, `proceeding_mapping.go`, `export_service.go` | No code changes. Comments mentioning "the rule"/"rules" stay accurate as long as the file is about sequencing — only services that surface the **identity** aspect of the rule (`submission_vars.go`) need a prose pass. |
| `internal/handlers/submissions.go` | No SQL change. Type+comment renames: the catalog response type stays `submissionListEntry` (it's still a Schriftsatz-level list); doc comments speak of "procedural events whose kind is filing" instead of "rules of type filing". |
| `internal/handlers/admin_rules.go` | URL path stays. JSON envelope stays. Page-render comments + log-line text shift to "procedural event". |
| `internal/handlers/submission_drafts.go`, `deadlines.go`, `fristenrechner.go` | No service-layer change. |
### §6.2 Slice B — structural
Mostly load-bearing; not enumerated here in detail (out of scope per (R)=C). The shape:
- `RuleEditorService` splits into `ProceduralEventService` + `SequencingRuleService` + `LegalSourceService`. The Save / Publish / Archive flow on the editor coordinates all three.
- `DeadlineRuleService.GetByID` becomes `SequencingRuleService.GetByID`; the `submission_code` lookup moves to `ProceduralEventService.GetByCode`.
- `SubmissionVarsService.loadPublishedRule` becomes `loadPublishedProceduralEvent` and returns a triple (`event`, `defaultSequencingRule`, `legalSource`); the variable-bag emission consumes all three.
- `ProjectionService` and the Fristenrechner calculator read from `sequencing_rules` (same column set, same logic — only the table name changes).
- `SubmissionService.list` (handlers/submissions.go) filters `procedural_events.event_kind IN ('filing', 'reply')`.
- Backfill orphans + audit triggers (mig 079 / 089) are re-pointed at `sequencing_rules` + a new `procedural_events_audit`.
---
## §7 UI / i18n impact
### §7.1 i18n keys (Slice A)
Existing keys (DE + EN) at `frontend/src/client/i18n.ts` lines ~2834-2920 and ~5800-5890 — surface area is *labels*, not *placeholders-in-Word*:
| Old key | New key (Slice A) | DE label | EN label |
|---|---|---|---|
| `admin.rules.list.title` | `admin.procedural_events.list.title` | "Verfahrensschritte verwalten — Paliad" | "Manage procedural events — Paliad" |
| `admin.rules.list.heading` | `admin.procedural_events.list.heading` | "Verfahrensschritte verwalten" | "Manage procedural events" |
| `admin.rules.list.subtitle` | `admin.procedural_events.list.subtitle` | "Verfahrensschritte anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived." | "Create, edit and publish procedural events. Lifecycle: draft → published → archived." |
| `admin.rules.list.new` | `admin.procedural_events.list.new` | "+ Neuer Verfahrensschritt" | "+ New procedural event" |
| `admin.rules.col.submission_code` | `admin.procedural_events.col.code` | "Code" (drop "/ Einreichung-Kennung" — the new heading already disambiguates) | "Code" |
| `admin.rules.col.legal_citation` | `admin.procedural_events.col.legal_source` | "Rechtsgrundlage" | "Legal source" |
| `admin.rules.col.name` | `admin.procedural_events.col.name` | "Bezeichnung" | "Name" |
| `admin.rules.col.proceeding` | `admin.procedural_events.col.proceeding` | "Verfahrenstyp" | "Proceeding" |
| `admin.rules.col.priority` | `admin.procedural_events.col.priority` | "Priorität" | "Priority" |
| `admin.rules.col.lifecycle` | `admin.procedural_events.col.lifecycle` | "Lifecycle" | "Lifecycle" |
| `admin.rules.col.modified` | `admin.procedural_events.col.modified` | "Zuletzt geändert" | "Last modified" |
| `admin.rules.edit.title` | `admin.procedural_events.edit.title` | "Verfahrensschritt bearbeiten — Paliad" | "Edit procedural event — Paliad" |
| `admin.rules.edit.heading.loading` | `admin.procedural_events.edit.heading.loading` | "Verfahrensschritt laden…" | "Loading procedural event…" |
| `admin.rules.edit.breadcrumb` | `admin.procedural_events.edit.breadcrumb` | "← Verfahrensschritte verwalten" | "← Manage procedural events" |
| `admin.rules.edit.field.submission_code` | `admin.procedural_events.edit.field.code` | "Code (Schriftsatz-Code / Einreichung-Kennung)" — keep the parenthetical so lawyers familiar with the old label know what they're looking at. | "Code (submission / procedural-event identifier)" |
| `admin.rules.edit.field.rule_code` | `admin.procedural_events.edit.field.short_citation` | "Rechtsgrundlage (Kurzform)" | "Legal source (short form)" |
| `admin.rules.edit.field.legal_source` | `admin.procedural_events.edit.field.legal_source` | "Rechtsgrundlage (Langform)" | "Legal source (long form)" |
| `admin.rules.edit.field.name` | `admin.procedural_events.edit.field.name` | "Bezeichnung (DE)" | "Name (DE)" |
| `admin.rules.edit.field.name_en` | `admin.procedural_events.edit.field.name_en` | "Bezeichnung (EN)" | "Name (EN)" |
| `admin.rules.edit.field.proceeding` | `admin.procedural_events.edit.field.proceeding` | "Verfahrenstyp" | "Proceeding type" |
| `admin.rules.edit.field.trigger` | `admin.procedural_events.edit.field.trigger` | "Trigger-Ereignis" | "Trigger event" |
| `admin.rules.edit.field.parent` | `admin.procedural_events.edit.field.parent` | "Übergeordneter Verfahrensschritt (UUID)" | "Parent procedural event (UUID)" |
| `admin.rules.edit.field.concept` | `admin.procedural_events.edit.field.concept` | "Konzept (UUID)" | "Concept (UUID)" |
| `admin.rules.edit.field.sequence_order` | `admin.procedural_events.edit.field.sequence_order` | "Reihenfolge" | "Order" |
| `admin.rules.edit.field.duration_value` | `admin.procedural_events.edit.field.duration_value` | "Dauer" | "Duration" |
| `admin.rules.edit.field.primary_party` | `admin.procedural_events.edit.field.primary_party` | "Partei (typisch)" | "Primary party" |
| `admin.rules.edit.field.event_type` | `admin.procedural_events.edit.field.event_kind` | "Art des Verfahrensschritts" | "Procedural-event kind" |
| `admin.rules.edit.field.description` | `admin.procedural_events.edit.field.description` | "Beschreibung" | "Description" |
**Legacy keys retained as aliases** so any existing translation imports or external integrations keep working — old keys point at the same DE/EN values during a deprecation window of one full Slice B cycle.
### §7.2 Variable-bag placeholders (Slice A)
`frontend/src/client/submission-draft.ts:155-185` — the catalog of placeholders the lawyer sees in the sidebar:
| Old placeholder (kept as legacy alias) | New canonical placeholder | DE label | EN label |
|---|---|---|---|
| `{{rule.submission_code}}` | `{{procedural_event.code}}` | "Code (Verfahrensschritt)" | "Code (procedural event)" |
| `{{rule.name}}` | `{{procedural_event.name}}` | "Bezeichnung" | "Name" |
| `{{rule.name_de}}` | `{{procedural_event.name_de}}` | "Bezeichnung (DE)" | "Name (DE)" |
| `{{rule.name_en}}` | `{{procedural_event.name_en}}` | "Bezeichnung (EN)" | "Name (EN)" |
| `{{rule.legal_source}}` | `{{procedural_event.legal_source}}` | "Rechtsgrundlage (Code)" | "Legal source (code)" |
| `{{rule.legal_source_pretty}}` | `{{procedural_event.legal_source_pretty}}` | "Rechtsgrundlage" | "Legal source" |
| `{{rule.primary_party}}` | `{{procedural_event.primary_party}}` | "Partei (typisch)" | "Primary party" |
| `{{rule.event_type}}` | `{{procedural_event.event_kind}}` | "Art des Verfahrensschritts" | "Procedural-event kind" |
The catalog renders the canonical name in the "copy-this-placeholder" button. The variable bag (`submission_vars.go`) emits both names with identical values, so any Word template the lawyer already has continues to work; new templates are encouraged to use the canonical name.
### §7.3 Admin rule-editor form (Slice A)
`frontend/src/admin-rules-edit.tsx:74-110` — i18n key rebinds + heading text update. The DOM `id` attributes (`f-submission-code`, `f-rule-code`, `f-legal-source`, …) stay — they're internal, the rename here is cosmetic, the form still POSTs the same JSON envelope (Slice A doesn't touch the API). The fieldset `legend` for the "Identität" section changes to "Verfahrensschritt-Identität" (DE) / "Procedural-event identity" (EN). The "Verfahren & Trigger" section heading stays — that section is about sequencing, and Slice A doesn't rename sequencing-level labels (those are Slice B).
### §7.4 Project-detail Schriftsätze tab + dashboard
`frontend/src/client/submissions.ts`, `submissions-index.ts`: no surface-level label change in Slice A. The Schriftsätze tab continues to show Schriftsätze (the lawyer's preferred term for *filings specifically*). The tab is a filtered view onto procedural events of kind `filing`/`reply` — that distinction surfaces only in admin contexts.
### §7.5 Help text + docs
A short addition to the in-app help: "What is a procedural event?" — one-paragraph definition explaining the umbrella term, with examples (Klage, Klageerwiderung, mündliche Verhandlung, Endurteil). Stored in `frontend/src/client/i18n.ts` under `help.procedural_events.intro`. Out of scope for the URL/router changes — added as static copy where it fits naturally.
---
## §8 Slice plan
### §8.1 Slice A (this design's downstream task)
**Scope:** prose-only rename per §3 ("renamed in Slice A" list).
**Mechanics:**
1. Add 8 new placeholder keys to the variable bag in `submission_vars.go` (1:1 with the existing 8 `rule.*` keys). Keep the legacy keys.
2. Update `frontend/src/client/submission-draft.ts` placeholder catalog labels.
3. Rebind admin i18n keys per §7.1 (with legacy keys retained).
4. Update admin page titles + section headings.
5. Update Go struct comments + service docstrings in `submission_vars.go`, `deadline_rule_service.go`, `rule_editor_service.go`, `submission_draft_service.go`, `submissions.go` handler. No code-flow change.
6. Update `internal/handlers/submissions.go` doc comments.
7. Add a short `docs/glossary.md` entry (or extend an existing one) for "procedural event" / "Verfahrensschritt" — single source of truth for the term.
8. Tests: rename strings in existing test fixtures + add a regression test that the variable bag emits **both** the legacy `rule.X` and the canonical `procedural_event.X` keys with the same value. (Critical — without this test, a future commit could drop the legacy alias and silently break user templates.)
9. Manual smoke: open the admin rule editor, confirm the new title appears. Open the submission-draft editor, confirm both `{{rule.X}}` and `{{procedural_event.X}}` placeholders are listed (with canonical first). Generate a `.docx` from a project using each placeholder name — both render identically.
**Risk:** very low. No DB change, no API change, fully reversible.
**No hours estimate per project CLAUDE.md.**
### §8.2 Slice B (separate mai task — designed here, hired later)
**Scope:** structural rework per §4 + §5.
**Mechanics:** Phase 1 → Phase 4 per §5.
**Prerequisite:** m greenlights via a new mai task with this doc + §11's open items addressed. **Not part of Slice A.**
**Sub-slices (suggested for Slice B's own task):**
- **B.0** — Re-validate this doc's premises against live DB (numbers shift over weeks).
- **B.1** — Phase 1 additive migration + backfill (mig 124).
- **B.2** — Phase 2 dual-write + read-fallback.
- **B.3** — Phase 3 read cutover (no schema change).
- **B.4** — Phase 4 destructive drop (downtime window).
- **B.5** — Rename Go types `DeadlineRule``SequencingRule` + `ProceduralEvent`; rename JSON API envelope keys with a deprecation header. Independent of B.4.
- **B.6** — Rename admin URL paths `/admin/rules``/admin/procedural-events` with redirects. Optional / low-priority.
### §8.3 Why splitting is the right call
The conflation is real, but the *fix* for the most-painful surface (the editor sidebar) is independent of the table restructure. Splitting lets m ship the fix this week, see whether the prose change alone resolves enough of the cognitive friction, and then decide whether the structural rework is still worth the migration cost. If after Slice A m says "this reads fine now, B isn't worth it", that's a legitimate outcome — Slice B is a *good* refactor, not an *urgent* one.
---
## §9 Risk assessment
### §9.1 Slice A risks
| Risk | Likelihood | Severity | Mitigation |
|---|---|---|---|
| Lawyer's existing Word template has `{{rule.submission_code}}` baked in; a future commit drops the legacy alias and breaks templates. | Low (Slice A keeps the alias) | High if it happens | Regression test (§8.1 step 8) asserts both keys emit. Add an audit-log line on every variable-bag call recording which keys were consumed by the merge engine — gives a 30-day window of evidence before we'd consider deprecating the legacy keys. |
| i18n key rename misses a binding, leaving an English string visible to a DE user. | Medium | Low | The build pipeline (`bun test` / `bun build`) fails on missing i18n keys in `i18n-keys.ts`. Add the new keys to the type union; leave the old keys in the union with `@deprecated` JSDoc. |
| Renamed admin page heading confuses returning admin users ("Where did 'Regeln verwalten' go?"). | Medium | Low | One-time changelog entry; the URL `/admin/rules` is unchanged so muscle memory still lands them on the page. Internal users only (whitelist-gated). |
| Slice A reads as "we're done" and Slice B never ships. | Medium | Medium (the model stays wrong) | This doc files the Slice B design as a separate task entry **before** Slice A merges, so the to-do is visible. m's call whether to schedule it. |
### §9.2 Slice B risks (deferred; recorded for the future task)
| Risk | Mitigation |
|---|---|
| Backfill collapses too eagerly: 10 multi-row submission_codes today are `_archived_litigation.*` — confirm they should collapse into one procedural event with 2-5 sequencing variants, vs. each row becoming its own procedural event. | The `_archived_litigation.*` codes are archived per their prefix — collapse is safe. **Decision-flag for Slice B's own design pass.** |
| `deadline_concepts` linkage (125 of 254 rules link to a concept) — does the concept attach to the procedural event or the sequencing rule? §4.2 says procedural event; verify this is right when re-validating premises in B.0. | Read-path audit: every consumer that joins `deadline_rules.concept_id` (rule_editor, projection, fristenrechner) operates on the rule-level today. Reconfirm none of them depend on per-jurisdiction concept-attachment. |
| The dual-write window introduces drift if a write hits one side and fails on the other. | Atomicity via single transaction per write in `RuleEditorService`. Daily drift-check job (one SELECT pair, alert if mismatched). |
| `paliad.deadlines.rule_id` (1 live row, but more in future) — backfilling `procedural_event_id` + `sequencing_rule_id` must not orphan the live row. | The 1 live row joins cleanly. Backfill in the same migration that adds the new columns. |
| The submission-draft `submission_code` text key — what if two `procedural_events.code` values collide post-rename (e.g. a draft was saved against a code that we then archive)? | Slice B Phase 1 enforces `procedural_events.code UNIQUE`; the backfill verifies no collision on the existing 158 distinct values. Drafts with codes that no longer exist as published procedural events are handled by the existing `submission_drafts.submission_code` text fallback (no FK enforcement). |
| Slice B's API-key rename (`submission_code``code` in JSON) breaks external integrations. | None exist today (paliad is internal-only); add a one-Slice deprecation header (`X-Deprecated-Field: submission_code`) before flipping. |
| **Coordination risk with future fristen/calculator work.** The Fristenrechner calculator reads `deadline_rules` directly today. Slice B Phase 2's read-fallback handles this, but a parallel calculator feature in flight could land changes that need re-merging. | B.0's job: confirm no in-flight task touches `deadline_rules` table shape before scheduling. |
### §9.3 What rolls Slice A back
`git revert <slice-a-commit>` + reload. Zero data side-effects (no DB writes). 30 seconds.
### §9.4 What rolls Slice B back
Per phase — Phases 1-3 reversible via reverting code + `DROP TABLE`. Phase 4 reversible only by restoring `deadline_rules` from the `_pre_<n>` snapshot taken at the start of Phase 4. Same posture as mig 091 — m's call when to commit to this point.
---
## §10 Out of scope
- **Renaming `paliad.events`** (the audit feed). Distinct table, distinct concept. The umbrella-term lock (§2) deliberately uses "procedural event" not "event" to avoid colliding with it.
- **Renaming `paliad.deadline_concepts`** to align with the procedural-event taxonomy. The concept layer is the cross-proceeding semantic bridge (einstein 2026-05-08 Q5); the relationship "procedural event has-a concept" already reads cleanly under the new term.
- **Per-jurisdiction variations of the same procedural event** (issue body's explicit out-of-scope). The 10 multi-row codes in the corpus today stay multi-row.
- **Multi-tenant / cross-firm sharing of procedural events** — paliad is single-tenant per deploy via `FIRM_NAME`; cross-firm is a separate design.
- **einstein's `proceeding_event_edges` graph proposal.** That design proposed a graph of typed event-types connected by typed edges. This design's procedural-events / sequencing-rules split is **compatible** with that graph shape (the edges would attach to procedural-event-IDs rather than sequencing-rule-IDs), but the graph layer is a Slice C, not Slice B. Flagged for future continuity, not part of either slice here.
- **Renaming Go type `DeadlineRule` to `SequencingRule` or `ProceduralEvent` in Slice A.** Slice A is prose; Slice B's B.5 sub-slice handles the type rename. Coupling them costs the reversibility property.
- **API-envelope key renames** (`submission_code``code`, `event_type``event_kind` on the wire). Slice B only.
- **URL path renames** (`/admin/rules``/admin/procedural-events`). Slice B.6, optional.
- **Touching `paliad.trigger_events`** beyond keeping the FK path open (today `deadline_rules.trigger_event_id`; Slice B maps to `sequencing_rules.trigger_event_id`).
- **Touching `paliad.event_categories` / Pathway-B navigation.** Independent layer.
---
## §11 Open questions for m (escalated via `mai instruct head` per project CLAUDE.md)
Per project CLAUDE.md "Head answers questions — NO AskUserQuestion" rule, these are surfaced to head, not picked-as-chip with the user.
| ID | Question | Inventor recommendation | Material to head? |
|---|---|---|---|
| **Q1** | Scope: cosmetic-only (A) · full restructure (B) · cosmetic now + B as planned follow-up (C). | **(R) = C** | Yes — material. Defines whether Slice B is hired today or filed as a future task. |
| **Q2** | Umbrella term: "procedural event" (DE: Verfahrensschritt) · "submission" (filings only) · "Verfahrensereignis" · other. | **(R) = procedural event / Verfahrensschritt** | Yes — material. The term ripples through every label in §7. Inventor's pick is the canonical choice; head can override with a single message. |
| **Q3** | Slice B migration shape: confirmed (§4 + §5) or rescope. | **(R) = §4 + §5 as written, decision deferred until Slice B is hired** | No — informational. Locked when Slice B's own design pass runs. |
| **Q4** | Effect on Schriftsätze surface: filter `procedural_events.event_kind IN ('filing', 'reply')` is acceptable replacement for today's `event_type='filing'`. | **(R) = yes, semantically equivalent under Slice B; no behavior change to lawyer.** | No — informational. |
| **Q5** | Are the 10 archived multi-row submission_codes (`_archived_litigation.*`) safe to collapse into single procedural events with multiple sequencing variants in Slice B? | **(R) = yes, prefix indicates archival; collapse-safe.** | No — informational, defers to Slice B. |
| **Q6** | `concept_id` attaches to procedural event, not sequencing rule. Confirmable? | **(R) = yes, per §4.2 (one concept per identity, not per jurisdiction).** | No — informational, defers to Slice B. |
| **Q7** | Keep the legacy `{{rule.X}}` placeholder aliases **forever**, or set a deprecation horizon (e.g. 1 year)? | **(R) = forever, with `@deprecated` annotation in the catalog. Removing them risks breaking lawyer-authored templates that paliad doesn't see.** | Yes — material to Slice A's contract (test in §8.1 step 8 asserts both keys emit). |
| **Q8** | Document side: update m/paliad#93 issue body to fix the `deadlines.deadline_rule_id``deadlines.rule_id` typo (§1 last paragraph). | **(R) = yes, head's call when to edit.** | No — informational, doc hygiene. |
| **Q9** | After Slice A ships, do we file Slice B as a new mai task **now** (so it's visible), or wait for m to ask? | **(R) = file now, status:planning, no owner. Visibility >> deferred surprise.** | Yes — material to "does the model stay wrong forever". |
Q1, Q2, Q7, Q9 are the four head needs to answer before the coder shift. Q3-Q6, Q8 defer cleanly.
---
## §12 Appendix — verbatim m quote
From m's report 2026-05-25 15:02 (paliad#93 body):
> This shows how our 'rule' table system may need a revision?! It feels like we are rule based not submission based. But here we have a specific submission that is connected to a rule (as in: legal norm). And of course also connected to other 'procedural events' (which is a good term for it all) by rules how they are sequenced. But it makes it sound weird in the fields...
The design above takes m's three-way split — *the procedural event* / *the legal norm* / *the rule by which they are sequenced* — at face value and turns it into a column-level map (§4.2), a slice plan (§8), and a deprecation contract (§9.1).
---
*End of design.*

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",
@@ -312,6 +312,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Zeitstrahl",
"deadlines.view.columns": "Spalten",
"deadlines.notes.show": "Hinweise anzeigen",
"deadlines.durations.show": "Dauern anzeigen",
"deadlines.col.ours": "Unsere Seite",
"deadlines.col.court": "Gericht",
"deadlines.col.opponent": "Gegnerseite",
@@ -462,10 +463,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 +3331,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",
@@ -3410,6 +3407,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Timeline",
"deadlines.view.columns": "Columns",
"deadlines.notes.show": "Show details",
"deadlines.durations.show": "Show durations",
"deadlines.col.ours": "Client Side",
"deadlines.col.court": "Court",
"deadlines.col.opponent": "Opponent Side",
@@ -3567,10 +3565,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,21 @@ 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.
// 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.
//
// 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.
// upc.apl.unified is NOT in this set since t-paliad-307: appeal
// timelines route via per-rule appealRole (engine-stamped under
// appeal_target) instead of the page-level appellant axis collapse.
// Adding upc.apl.unified here would short-circuit the appealAware
// path and re-introduce the dead side selector on upc.apl.unified
// (m/paliad#136 Bug 1).
const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl",
"de.inf.olg",
"de.inf.bgh",
"de.null.bgh",
@@ -73,12 +77,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 +147,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 +154,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
@@ -225,6 +282,21 @@ function writeNotesPref(on: boolean): void {
}
let showNotes = readNotesPref();
// Durations toggle (m/paliad#133, t-paliad-302) — when off (default),
// the per-rule duration label ("2 Mo. nach") only shows on hover via
// the date span's `title` attribute. When on, the label renders inline
// in the timeline meta row of every event card. Persisted in
// localStorage under its own key so the preference is independent of
// "Hinweise anzeigen".
const DURATIONS_PREF_KEY = "paliad.verfahrensablauf.durations-show";
function readDurationsPref(): boolean {
try { return localStorage.getItem(DURATIONS_PREF_KEY) === "1"; } catch { return false; }
}
function writeDurationsPref(on: boolean): void {
try { localStorage.setItem(DURATIONS_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showDurations = readDurationsPref();
// Jurisdiction display prefix for the proceeding-summary chip + the
// trigger-event placeholder. Same forum slugs the .proceeding-group
// `data-forum` attribute carries in verfahrensablauf.tsx /
@@ -431,10 +503,22 @@ function renderResults(data: DeadlineResponse) {
? renderColumnsBody(data, {
editable: true,
showNotes,
showDurations,
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,
// Appeal-target proceedings get per-rule appealRole routing
// instead of the page-level appellant collapse, so the side
// selector actually splits Berufungskläger vs Berufungs-
// beklagter filings across columns. (t-paliad-307 /
// m/paliad#136 Bug 1)
appealAware: hasAppealTarget(selectedType),
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
: renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
@@ -501,8 +585,8 @@ function selectProceeding(btn: HTMLButtonElement) {
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
syncAppellantRowVisibility();
syncAppealTargetRowVisibility();
applyRoleLabels(selectedType);
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
@@ -510,23 +594,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 +794,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 +810,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.
@@ -841,6 +896,19 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// Durations toggle (m/paliad#133, t-paliad-302) — sibling of the
// notes toggle. Hover-only labels (default) become inline labels when
// the user opts in.
const durationsShowCb = document.getElementById("verfahrensablauf-durations-show") as HTMLInputElement | null;
if (durationsShowCb) {
durationsShowCb.checked = showDurations;
durationsShowCb.addEventListener("change", () => {
showDurations = durationsShowCb.checked;
writeDurationsPref(showDurations);
if (lastResponse) renderResults(lastResponse);
});
}
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
// to URL + recalc (the backend reshapes the response — we can't just
// re-render lastResponse since the hidden rows aren't in it when the

View File

@@ -4,7 +4,9 @@ import {
type DeadlineResponse,
bucketDeadlinesIntoColumns,
deadlineCardHtml,
formatDurationLabel,
renderColumnsBody,
stripLeadingDurationFromNotes,
} from "./verfahrensablauf-core";
// Regression tests for the editable→click-to-edit wiring on timeline date
@@ -327,6 +329,29 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
expect(rows[0].ours).toHaveLength(0);
});
test("side=defendant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
// When the user has committed to a perspective via `?side=`, the
// mirror is visual noise: the same card renders twice on one row,
// once in 'Unsere Seite' and once in 'Gegnerseite'. The card's
// '↔ beide Seiten' indicator already conveys the both-parties
// semantic, so collapsing into ours is sufficient.
const rows = bucketDeadlinesIntoColumns(
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
{ side: "defendant" },
);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
expect(rows[0].opponent).toHaveLength(0);
});
test("side=claimant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
{ side: "claimant" },
);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
expect(rows[0].opponent).toHaveLength(0);
});
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
const sameDate = "2026-07-23";
const rows = bucketDeadlinesIntoColumns([
@@ -464,3 +489,287 @@ describe("renderColumnsBody — side-aware column header labels (m/paliad#127)",
expect(html).not.toContain(">Reaktiv<");
});
});
// t-paliad-307 / m/paliad#136 Bug 1 — appeal-aware column routing.
// All appeal rules carry party='both' (either side could be the
// appellant). With appealAware=true + dl.appealRole set, the bucketer
// routes by (filer matches user) instead of collapsing every 'both'
// row into the user's column. Without a side picked, the bucketer
// keeps the legacy mirror so every appeal rule is visible.
describe("bucketDeadlinesIntoColumns — appeal-aware routing (t-paliad-307)", () => {
const appeal = (
name: string,
role: "appellant" | "appellee",
due: string,
): CalculatedDeadline => ({
code: name,
name,
nameEN: name,
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: due,
originalDate: due,
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
appealRole: role,
});
const notice = appeal("Berufungseinlegung", "appellant", "2026-07-26");
const grounds = appeal("Berufungsbegründung", "appellant", "2026-09-26");
const response = appeal("Berufungserwiderung", "appellee", "2026-12-26");
test("appealAware + side=claimant: appellant rules → ours, appellee rules → opponent", () => {
const rows = bucketDeadlinesIntoColumns([notice, grounds, response], {
side: "claimant",
appealAware: true,
});
const byKey = new Map(rows.map((r) => [r.key, r]));
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(notice.dueDate)?.opponent).toHaveLength(0);
expect(byKey.get(response.dueDate)?.ours).toHaveLength(0);
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
});
test("appealAware + side=defendant: appellant rules → opponent, appellee rules → ours", () => {
const rows = bucketDeadlinesIntoColumns([notice, response], {
side: "defendant",
appealAware: true,
});
const byKey = new Map(rows.map((r) => [r.key, r]));
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(notice.dueDate)?.ours).toHaveLength(0);
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
expect(byKey.get(response.dueDate)?.opponent).toHaveLength(0);
});
test("appealAware + side=null: mirror to both columns (every rule visible)", () => {
const rows = bucketDeadlinesIntoColumns([notice, response], {
side: null,
appealAware: true,
});
const byKey = new Map(rows.map((r) => [r.key, r]));
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
});
test("appealAware off: appealRole is ignored and legacy bucketing applies", () => {
// Regression guard: a stale frontend that drops `appealAware: true`
// must not silently route via appealRole — the side selector
// would visibly change behaviour without a UI control to opt in.
const rows = bucketDeadlinesIntoColumns([notice, response], { side: "defendant" });
// Legacy "side without appellant" collapse → both rows into ours.
const allOurs = rows.flatMap((r) => r.ours.map((d) => d.name));
expect(allOurs).toEqual(["Berufungseinlegung", "Berufungserwiderung"]);
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
});
test("appealAware respects court party — court rows always route to court column", () => {
const decision: CalculatedDeadline = {
...notice,
name: "Entscheidung",
party: "court",
appealRole: "", // court events deliberately stay empty
dueDate: "",
};
const rows = bucketDeadlinesIntoColumns([decision], { side: "claimant", appealAware: true });
expect(rows[0].court.map((d) => d.name)).toEqual(["Entscheidung"]);
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent).toHaveLength(0);
});
test("appealAware + rule without appealRole falls back to legacy bucketing", () => {
// A future appeal rule we forgot to map: appealRole='' falls
// through the appealAware branch and lands in the legacy
// side-collapse path → ours.
const unmapped: CalculatedDeadline = { ...notice, appealRole: "" };
const rows = bucketDeadlinesIntoColumns([unmapped], { side: "claimant", appealAware: true });
expect(rows[0].ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(rows[0].opponent).toHaveLength(0);
});
});
// t-paliad-307 / m/paliad#136 Bug 3 — duration label appends the
// parent rule name (or the proceeding's trigger event label for
// root rules) so the chip reads "4 Monate nach Endentscheidung"
// instead of the dangling "4 Monate nach".
describe("formatDurationLabel — appends parent name (t-paliad-307)", () => {
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
code: "x",
name: "x",
nameEN: "x",
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: "",
originalDate: "",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
durationValue: 4,
durationUnit: "months",
timing: "after",
...overrides,
});
test("with parent label: appends to head", () => {
expect(formatDurationLabel(dl(), "Endentscheidung (R.118)"))
.toBe("4 Monate nach Endentscheidung (R.118)");
});
test("without parent label: bare head — caller decides whether to render", () => {
expect(formatDurationLabel(dl())).toBe("4 Monate nach");
});
test("without timing: parent is not appended (degenerate phrasing)", () => {
// No timing == we can't form "4 Monate <timing> <parent>" cleanly,
// so the bare "4 Monate" head stays. Pinned to catch a future
// edit that would emit "4 Monate Endentscheidung" without a
// preposition.
expect(formatDurationLabel(dl({ timing: "" }), "Endentscheidung")).toBe("4 Monate");
});
test("singular value: switches to .one unit key", () => {
expect(formatDurationLabel(dl({ durationValue: 1 }), "X")).toBe("1 Monat nach X");
});
test("zero / missing duration: empty string", () => {
expect(formatDurationLabel(dl({ durationValue: 0 }), "X")).toBe("");
expect(formatDurationLabel(dl({ durationValue: 0, durationUnit: "" }), "X")).toBe("");
});
});
describe("deadlineCardHtml — duration tooltip reads parent name (t-paliad-307)", () => {
test("root rule with non-zero duration uses opts.triggerEventLabel as parent fallback", () => {
// upc.apl.merits.notice has no parent_id but a 2-month duration
// off the trigger event (the appealed decision). The duration
// tooltip must read the appeal-target label, not just "2 Monate
// nach".
const dl: CalculatedDeadline = {
code: "upc.apl.merits.notice",
name: "Berufungseinlegung",
nameEN: "Notice of Appeal",
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: "2026-07-26",
originalDate: "2026-07-26",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
durationValue: 2,
durationUnit: "months",
timing: "after",
};
const html = deadlineCardHtml(dl, {
showParty: false,
editable: true,
triggerEventLabel: "Endentscheidung (R.118)",
});
expect(html).toContain("title=\"2 Monate nach Endentscheidung (R.118)\"");
});
test("non-root rule prefers parent rule name over triggerEventLabel", () => {
// merits.response chains off merits.grounds; the duration label
// should read "3 Monate nach Berufungsbegründung", not the
// appeal-target fallback.
const dl: CalculatedDeadline = {
code: "upc.apl.merits.response",
name: "Berufungserwiderung",
nameEN: "Response to Appeal",
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: "2026-12-26",
originalDate: "2026-12-26",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
durationValue: 3,
durationUnit: "months",
timing: "after",
parentRuleCode: "upc.apl.merits.grounds",
parentRuleName: "Berufungsbegründung",
parentRuleNameEN: "Statement of Grounds",
};
const html = deadlineCardHtml(dl, {
showParty: false,
editable: true,
triggerEventLabel: "Endentscheidung (R.118)",
});
expect(html).toContain("title=\"3 Monate nach Berufungsbegründung\"");
});
});
// t-paliad-307 / m/paliad#136 Bug 4 — leading "Frist N <unit> …"
// substring is stripped before deadline_notes renders so the new
// duration affordance and the legacy free-text don't duplicate.
describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", () => {
test("DE: strips 'Frist 1 Monat VOR …. ' and keeps the rest", () => {
const out = stripLeadingDurationFromNotes(
"Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung.",
"de",
);
expect(out).toBe("Antrag auf Simultanübersetzung.");
});
test("DE: strips 'Frist 15 Tage ab …' when the whole notes is the duration prose", () => {
const out = stripLeadingDurationFromNotes(
"Frist 15 Tage ab Zustellung der Kostenentscheidung",
"de",
);
expect(out).toBe("");
});
test("DE: strips 'Frist beträgt 2 Monate ab …. ' (Wiedereinsetzung variant)", () => {
const out = stripLeadingDurationFromNotes(
"Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens 1 Jahr.",
"de",
);
expect(out).toBe("Spätestens 1 Jahr.");
});
test("DE: composite 'Frist N … ODER M …' is preserved (option b follow-up)", () => {
const composite =
"Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme.";
expect(stripLeadingDurationFromNotes(composite, "de")).toBe(composite);
});
test("DE: 'Frist vom Gericht' (no number) is preserved", () => {
const out = stripLeadingDurationFromNotes("Frist vom Gericht bestimmt", "de");
expect(out).toBe("Frist vom Gericht bestimmt");
});
test("EN: strips '1 month BEFORE …. ' and keeps the rest", () => {
const out = stripLeadingDurationFromNotes(
"1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation.",
"en",
);
expect(out).toBe("Request for simultaneous interpretation.");
});
test("EN: strips '15-day period from …'", () => {
const out = stripLeadingDurationFromNotes(
"15-day period from service of the cost decision",
"en",
);
expect(out).toBe("");
});
test("EN: strips 'Period is N <unit> from …'", () => {
const out = stripLeadingDurationFromNotes(
"Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest 12 months.",
"en",
);
expect(out).toBe("Latest 12 months.");
});
test("EN: empty / non-matching notes pass through unchanged", () => {
expect(stripLeadingDurationFromNotes("", "en")).toBe("");
expect(stripLeadingDurationFromNotes("Time limit set by the court", "en"))
.toBe("Time limit set by the court");
});
});

View File

@@ -95,6 +95,111 @@ export interface CalculatedDeadline {
parentRuleCode?: string;
parentRuleName?: string;
parentRuleNameEN?: string;
// durationValue / durationUnit / timing surface the rule's arithmetic
// so the timeline card can show "2 Mo. nach" on hover (and inline when
// the "Dauern anzeigen" toggle is on). Zero-duration rules (root
// event, court-set) carry durationValue=0 and the renderer suppresses
// the affordance — those don't have an explainable interval.
// (m/paliad#133, t-paliad-302)
durationValue?: number;
durationUnit?: string;
timing?: string;
// appealRole carries the rule's appeal-filer identity when the
// server computed the timeline under an appeal_target filter:
// "appellant" (Berufungskläger files this rule), "appellee"
// (Berufungsbeklagter files this rule), or empty for court events
// and non-appeal timelines. The column bucketer reads this in
// preference to primary_party='both' so a user-perspective `?side=`
// pick can split appeal filings into the user's column vs the
// opponent's, instead of routing every "both" rule into the
// user's column. (t-paliad-307 / m/paliad#136 Bug 1)
appealRole?: "appellant" | "appellee" | "";
// isTriggerEvent marks the synthetic row the engine prepends to the
// timeline when computing an appeal: a court-set decision dated to
// the trigger date with the per-appeal-target label
// (Endentscheidung / Kostenentscheidung / Anordnung / …). The row
// carries no real rule_id — it's a UI marker so the timeline reads
// decision → appeal filings → next decision. (t-paliad-307 /
// m/paliad#136 Bug 2)
isTriggerEvent?: boolean;
}
// stripLeadingDurationFromNotes drops the leading
// "Frist N <unit> <preposition> <subject>." (DE) /
// "N <unit> <preposition> <subject>." (EN) prefix from a rule's
// deadline_notes so it doesn't duplicate the new duration affordance
// added in m/paliad#133 (t-paliad-307 Bug 4).
//
// The duration affordance now renders the same prose as a badge on
// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text
// notes string that opens with the same prose reads as a verbatim
// duplicate. Only the leading-prefix shape is stripped — anything
// after the first sentence is preserved (the editorial commentary
// the lawyers actually want to read).
//
// Conservative: composite-duration prefaces with "ODER" /
// "whichever is the longer" don't match and stay untouched — those
// are the follow-up editorial cleanup (option b in the issue brief).
//
// Examples:
// "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …"
// → "Antrag …"
// "Frist 15 Tage ab Zustellung der Kostenentscheidung"
// → ""
// "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …"
// → "Spätestens …"
// "1-month period from service of the main decision"
// → ""
// "1 month BEFORE the oral hearing (R.109.1). Request for …"
// → "Request for …"
// "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …"
// → "Latest …"
// "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …"
// → unchanged (composite — option b follow-up)
export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string {
if (!notes) return notes;
// Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary
// (period followed by whitespace) OR end of input. Embedded dots
// inside parenthesised citations (R.109.1, § 123(2), Rule 136(1))
// are skipped because the char right after them isn't whitespace.
// `[^]*?` is the JS-portable form of `.*?` with the dotAll flag —
// any character including newlines, non-greedy.
const re = lang === "en"
? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i
: /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/;
return notes.replace(re, "");
}
// formatDurationLabel renders the per-rule duration label for the
// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung",
// "1 Monat vor Mündlicher Verhandlung", …
// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 /
// m/paliad#136 Bug 3).
//
// Returns empty string for rules without a usable duration so the
// caller can skip the tooltip / inline span entirely. Pluralisation
// key naming mirrors the Fristenrechner event-mode renderer
// (deadlines.event.unit.<unit>.{one,many}) — the unit and timing
// translations already exist for /tools/fristenrechner's
// "Was kommt nach…" mode and are reused here as the single source
// of truth.
//
// `parentLabel` is the rule's anchor name (parent rule's name when
// the rule has a parent_id; otherwise the proceeding's
// triggerEventLabel from the wire). Empty falls back to bare
// "<n> <unit> <timing>" — bare phrasing is the pre-fix shape and
// remains the default for fixtures / tests that omit a parent.
export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): string {
const value = dl.durationValue ?? 0;
const unit = dl.durationUnit || "";
if (value <= 0 || !unit) return "";
const unitKey = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
const unitStr = tDyn(unitKey);
const timing = dl.timing || "";
const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
if (!timingStr || !parentLabel) return head;
return `${head} ${parentLabel}`;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -321,15 +426,56 @@ export interface CardOpts {
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
// re-renders. Default false — notes are noisy on long timelines.
showNotes?: boolean;
// showDurations controls per-rule duration rendering on event cards
// (m/paliad#133, t-paliad-302):
// true → inline `<span class="timeline-duration">2 Mo. nach</span>`
// next to the date.
// false → hover-only tooltip on the date span (browser-native
// `title` attribute). Cards without a usable
// `durationValue > 0` get neither — court-set and trigger-
// event cards have no explainable interval.
// /tools/verfahrensablauf exposes a toggle ("Dauern anzeigen") that
// flips this and re-renders; persisted via the localStorage key
// `paliad.verfahrensablauf.durations-show`. Default false.
showDurations?: boolean;
// triggerEventLabel: per-language label of the proceeding's anchor
// event ("Endentscheidung (R.118)" for an Endentscheidung appeal;
// "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel
// as the parent-name fallback when a rule is a root rule (no
// parent_id) but carries a non-zero duration — e.g. the
// Berufungseinlegung 2 months after Endentscheidung. Pages pass the
// already-language-resolved string. (t-paliad-307 / m/paliad#136
// Bug 3)
triggerEventLabel?: string;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
const wantsEditable = !!opts.editable;
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
// Parent name for the duration label (t-paliad-307 / m/paliad#136
// Bug 3): use the rule's parent if set, else fall back to the
// proceeding's trigger event label (e.g. "Endentscheidung (R.118)"
// for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi).
// Empty for rules whose anchor isn't surface-able — the duration
// label degrades to the bare "<n> <unit> <timing>" form in that case.
const parentLabelForDuration = (getLang() === "en"
? (dl.parentRuleNameEN || dl.parentRuleName)
: (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || "";
// Duration affordance (m/paliad#133, t-paliad-302). Computed once so
// both the date-span tooltip and the inline meta-row span pull from
// the same string. Empty for rules without a usable duration.
const durationLabel = formatDurationLabel(dl, parentLabelForDuration);
// Hover affordance on the date span: prefer the duration tooltip when
// we have one, else fall back to the edit-hint when the cell is
// click-to-edit. The edit affordance still works either way — the
// title is purely advisory.
const dateTitle = durationLabel
? durationLabel
: (editable ? t("deadlines.date.edit.hint") : "");
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0"${dateTitle ? ` title="${escAttr(dateTitle)}"` : ""}`
: (dateTitle ? ` title="${escAttr(dateTitle)}"` : "");
// Conditional rows (t-paliad-289) replace the date column with an
// "abhängig von <parent>" chip. The chip remains click-to-edit so
// the user can pin a real date once known (e.g. once the oral
@@ -425,7 +571,14 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
}
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
// Strip the leading-duration prefix so the new duration affordance
// doesn't duplicate what the lawyer wrote verbatim into deadline_notes
// for those legacy rule rows that still carry it.
// (t-paliad-307 / m/paliad#136 Bug 4)
const noteText = rawNoteText
? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de")
: rawNoteText;
const showNotes = opts.showNotes === true;
const notesBlock = noteText && showNotes
? `<div class="timeline-notes">${noteText}</div>`
@@ -434,9 +587,19 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
: "";
const meta = (opts.showParty || ruleRef || noteHint)
// Inline duration affordance (m/paliad#133, t-paliad-302). Only
// emitted when the "Dauern anzeigen" toggle is on AND the rule has a
// usable duration; the default-off hover-tooltip path is wired
// separately on the date span itself.
const showDurations = opts.showDurations === true;
const durationInline = showDurations && durationLabel
? `<span class="timeline-duration">${escHtml(durationLabel)}</span>`
: "";
const meta = (opts.showParty || ruleRef || noteHint || durationInline)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${durationInline}
${ruleRef}
${noteHint}
</div>`
@@ -545,7 +708,32 @@ export function wireDateEditClicks(
});
}
// pickTriggerEventLabel returns the per-language trigger event label
// from a DeadlineResponse, used as the parent-fallback for root-rule
// duration labels. Mirrors the precedence the page-level
// triggerEventLabelFor uses (curated server label > proceedingName
// fallback). Distinct from the page helper in that it stays language-
// scoped to the current getLang() — root-rule duration labels render
// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3)
export function pickTriggerEventLabel(data: DeadlineResponse): string {
const lang = getLang();
const curated = lang === "en"
? (data.triggerEventLabelEN || data.triggerEventLabel || "")
: (data.triggerEventLabel || data.triggerEventLabelEN || "");
if (curated) return curated;
return lang === "en"
? (data.proceedingNameEN || data.proceedingName || "")
: (data.proceedingName || data.proceedingNameEN || "");
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
// Resolve the trigger event label once so the duration affordance on
// root rules (no parent) can read it as the anchor fallback. Caller-
// provided value wins (lets the page override for sub-track flows).
const cardOpts: CardOpts = {
...opts,
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
};
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
const itemClasses = [
@@ -567,7 +755,7 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
${deadlineCardHtml(dl, opts)}
${deadlineCardHtml(dl, cardOpts)}
</div>
</div>
`;
@@ -614,6 +802,9 @@ type ColumnPosition = "ours" | "opponent";
export interface ColumnsBodyOpts {
editable?: boolean;
showNotes?: boolean;
// Forwarded to deadlineCardHtml — see CardOpts.showDurations.
// (m/paliad#133, t-paliad-302)
showDurations?: boolean;
// side: which side the user is on. Drives column placement;
// does NOT filter rows. Default null = claimant-on-the-left
// (i.e. "ours = claimant", legacy default).
@@ -623,6 +814,15 @@ export interface ColumnsBodyOpts {
// (no mirror). Default null = mirror "both" into both cells
// (legacy behaviour). Independent of `side`.
appellant?: Side;
// appealAware: forwarded to bucketDeadlinesIntoColumns when the
// page is rendering an appeal_target-filtered timeline. Routes
// each rule to its filer-perspective column via dl.appealRole
// instead of the legacy primary_party='both' collapse.
// (t-paliad-307 / m/paliad#136 Bug 1)
appealAware?: boolean;
// triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts.
// (t-paliad-307 / m/paliad#136 Bug 3)
triggerEventLabel?: string;
}
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
@@ -638,6 +838,15 @@ export interface ColumnsRow {
export interface BucketingOpts {
side?: Side;
appellant?: Side;
// appealAware: when true, rules carrying a `dl.appealRole` of
// "appellant" / "appellee" route via the appeal role + user side
// axis instead of the legacy primary_party='both' collapse. With
// `side=null` the bucketer keeps the mirror semantic (both columns
// render every appeal rule); with `side` set, "appellant" rules
// land in the user's column when the user IS the appellant, in
// the opponent's column otherwise — mirror for "appellee" rules.
// (t-paliad-307 / m/paliad#136 Bug 1)
appealAware?: boolean;
}
// bucketDeadlinesIntoColumns is the pure routing primitive that
@@ -672,6 +881,8 @@ export function bucketDeadlinesIntoColumns(
return r;
};
const appealAware = opts.appealAware === true;
deadlines.forEach((dl, idx) => {
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
@@ -694,11 +905,41 @@ export function bucketDeadlinesIntoColumns(
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
row[perCardCol].push(dl);
} else if (
appealAware &&
(dl.appealRole === "appellant" || dl.appealRole === "appellee")
) {
// Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1).
// With no side picked, mirror to both columns so every rule
// is visible regardless of which side the user is on. With
// a side picked, route by (filer matches user) → ours
// column, else opponent column. side=claimant maps the
// user to "appellant" (Berufungskläger); side=defendant
// maps the user to "appellee" (Berufungsbeklagter).
if (userSide === null) {
row.ours.push(dl);
row.opponent.push(dl);
} else {
const userIsAppellant = userSide === "claimant";
const filerIsAppellant = dl.appealRole === "appellant";
row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl);
}
} else if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
} else if (userSide !== null) {
// Side picked but no appellant axis (first-instance Inf, Rev,
// …): the user has committed to a perspective, so the mirror
// is visual noise — the same card appears twice on the same
// row, once in "Unsere Seite" and once in "Gegnerseite".
// Collapse into ours; the "↔ beide Seiten" indicator on the
// card already conveys that the rule applies to both parties.
// (m/paliad#135 / t-paliad-304)
row.ours.push(dl);
} else {
// No perspective picked → keep the legacy mirror so neither
// axis is privileged. Pinned by the "default (no opts)" test.
row.ours.push(dl);
row.opponent.push(dl);
}
@@ -721,15 +962,31 @@ export function bucketDeadlinesIntoColumns(
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
const userSide: Side = opts.side ?? null;
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
const rows = bucketDeadlinesIntoColumns(data.deadlines, {
side: userSide,
appellant: opts.appellant,
appealAware: opts.appealAware,
});
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
const cardOpts: CardOpts = {
showParty: false,
editable: opts.editable,
showNotes: opts.showNotes,
showDurations: opts.showDurations,
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
};
// Collapsed "both" rows lose their mirror tag — there's no longer
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
// be misleading. Keep it for the legacy mirror path.
const showMirrorTag = !appellantPinned;
// be misleading. Both collapse paths suppress it:
// - appellantPinned: role-swap collapse into appellant's column
// - userSide !== null without appellantPinned: perspective-locked
// collapse into ours (m/paliad#135 / t-paliad-304).
// Legacy mirror path (no side, no appellant) keeps the tag — both
// sibling rows still render so the tag has a visual referent.
const sideCollapse = userSide !== null;
const showMirrorTag = !appellantPinned && !sideCollapse;
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {

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"
@@ -1268,6 +1264,7 @@ export type I18nKey =
| "deadlines.dpma.appeal.bgh"
| "deadlines.dpma.appeal.bpatg"
| "deadlines.dpma.opp.dpma"
| "deadlines.durations.show"
| "deadlines.empty.filtered"
| "deadlines.empty.hint"
| "deadlines.empty.title"
@@ -1517,10 +1514,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

@@ -3750,6 +3750,16 @@ input[type="range"]::-moz-range-thumb {
color: var(--color-text-muted);
}
/* Per-rule duration label rendered inline in the meta row when
"Dauern anzeigen" is on (m/paliad#133, t-paliad-302). Matches the
sibling .timeline-rule weight so the meta line reads as one band of
secondary metadata; non-mono so the value reads as prose ("2 Mo. nach")
rather than a code reference. */
.timeline-duration {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.timeline-adjusted {
font-size: 0.78rem;
color: var(--status-amber-fg-2);

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.
@@ -358,6 +341,13 @@ export function renderVerfahrensablauf(): string {
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
{/* Durations toggle (m/paliad#133, t-paliad-302).
Default off — hover-tooltips on date spans are
the always-on path. */}
<label className="fristen-notes-option">
<input type="checkbox" id="verfahrensablauf-durations-show" />
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
</label>
</div>
<div id="timeline-container">

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

@@ -0,0 +1,7 @@
-- 139_deadline_rules_unified_view (down) — Slice B.3, t-paliad-305
--
-- Drops the view. The underlying paliad.sequencing_rules /
-- procedural_events / legal_sources tables are untouched (they own the
-- data — the view is just a projection).
DROP VIEW IF EXISTS paliad.deadline_rules_unified;

View File

@@ -0,0 +1,122 @@
-- 139_deadline_rules_unified_view — Slice B.3 read cutover (t-paliad-305 / m/paliad#93)
--
-- Creates paliad.deadline_rules_unified — a Postgres VIEW that
-- re-projects paliad.sequencing_rules + paliad.procedural_events +
-- paliad.legal_sources back into the legacy paliad.deadline_rules
-- column shape.
--
-- Why a view instead of rewriting every SELECT in Go:
--
-- - 19 read sites across 11 service files reference
-- paliad.deadline_rules. Rewriting each by hand multiplies the
-- opportunity for off-by-one bugs in the JOIN.
-- - The view has the same column names + types as the legacy table,
-- so the change in Go is a 1-token substitution per query
-- (FROM paliad.deadline_rules → FROM paliad.deadline_rules_unified)
-- with no struct or scanner changes.
-- - When B.4 drops paliad.deadline_rules, this view stays — it
-- becomes the canonical legacy-shape reader for any code that
-- hasn't been migrated to direct sr/pe/ls reads.
--
-- Column mapping (per design §4.2):
-- - id, proceeding_type_id, parent_id, primary_party, duration_*,
-- timing, sequence_order, is_spawn/court_set/bilateral, priority,
-- rule_code, rule_codes, deadline_notes(_en), condition_expr,
-- choices_offered, applies_to_target, trigger_event_id,
-- spawn_proceeding_type_id, anchor_alt, alt_duration_*,
-- alt_rule_code, combine_op, lifecycle_state, draft_of,
-- published_at, is_active, created_at, updated_at, spawn_label
-- → from paliad.sequencing_rules
-- - submission_code → procedural_events.code
-- - name, name_en, description→ procedural_events
-- - event_type → procedural_events.event_kind (renamed)
-- - concept_id → procedural_events
-- - legal_source → legal_sources.citation (via legal_source_id FK)
--
-- The view is READ-ONLY by default. Writes still go to the underlying
-- tables — RuleEditorService is refactored in the same slice to write
-- directly to sr/pe/ls. paliad.deadline_rules is FROZEN from B.3 onward
-- (no new writes); the dual-write helper from B.2 is decommissioned.
-- The CHECK constraint on sequencing_rules.primary_party doesn't exist
-- yet (mig 135 only constrained deadline_rules.primary_party). The view
-- inherits whatever value sr.primary_party carries; mig 136's backfill
-- set sr.primary_party = dr.primary_party so the canonical four-value
-- vocab is already in place. A later slice can add the same CHECK to
-- sequencing_rules itself.
CREATE OR REPLACE VIEW paliad.deadline_rules_unified AS
SELECT
sr.id,
sr.proceeding_type_id,
sr.parent_id,
pe.code AS submission_code,
pe.name,
pe.name_en,
pe.description,
sr.primary_party,
pe.event_kind AS event_type,
sr.duration_value,
sr.duration_unit,
sr.timing,
sr.alt_duration_value,
sr.alt_duration_unit,
sr.alt_rule_code,
sr.anchor_alt,
sr.combine_op,
sr.rule_code,
sr.deadline_notes,
sr.deadline_notes_en,
sr.sequence_order,
sr.is_spawn,
sr.spawn_label,
sr.spawn_proceeding_type_id,
sr.is_bilateral,
sr.is_court_set,
sr.priority,
sr.condition_expr,
pe.concept_id,
ls.citation AS legal_source,
sr.trigger_event_id,
sr.rule_codes,
sr.choices_offered,
sr.applies_to_target,
sr.lifecycle_state,
sr.draft_of,
sr.published_at,
sr.is_active,
sr.created_at,
sr.updated_at
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id;
COMMENT ON VIEW paliad.deadline_rules_unified IS
'Slice B.3 (mig 139, t-paliad-305): legacy-shape projection over '
'sequencing_rules + procedural_events + legal_sources. Read-only — '
'writes go directly to the three underlying tables via '
'RuleEditorService. Survives B.4 destructive drop of '
'paliad.deadline_rules; the view will then be the only '
'legacy-shape reader.';
-- Post-apply integrity check: confirm the view's row count matches the
-- live sequencing_rules row count. A mismatch would indicate either a
-- mid-deploy race (rare) or a JOIN issue (the LEFT JOIN to legal_sources
-- never drops rows, the INNER JOIN to procedural_events drops sr rows
-- whose procedural_event_id is NULL — but that column is NOT NULL on
-- the table so it can't happen). Belt-and-braces.
DO $$
DECLARE
v_view_count int;
v_sr_count int;
BEGIN
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
SELECT COUNT(*) INTO v_sr_count FROM paliad.sequencing_rules;
IF v_view_count <> v_sr_count THEN
RAISE EXCEPTION '[mig 139] FAILED POST: view row count % does not match sequencing_rules row count %. '
'Possible cause: a sequencing_rules row references a procedural_event_id that does not exist (NOT NULL FK should prevent this).',
v_view_count, v_sr_count;
END IF;
RAISE NOTICE '[mig 139] view OK — deadline_rules_unified rows = % (= sequencing_rules)',
v_view_count;
END $$;

View File

@@ -0,0 +1,47 @@
-- 140_drop_deadline_rules (down) — Slice B.4, t-paliad-305
--
-- Best-effort recovery from the deadline_rules_pre_140 snapshot. The
-- original triggers (mig 079 audit), indexes, CHECK constraints (mig
-- 135 primary_party), and FK constraints on the new tables are NOT
-- recreated here — restoring the working state requires replaying
-- migrations 078/079/091/095/098/122/128/134/135 against the restored
-- table.
--
-- Use this only for catastrophic recovery. The normal revert path
-- for B.4 is to re-deploy the previous container image (which still
-- writes via the dual-write helper to a paliad.deadline_rules that no
-- longer exists) — that would crash on first write, so true revert
-- requires this down + a code revert + a snapshot restore.
-- Drop the INSTEAD OF triggers + functions
DROP TRIGGER IF EXISTS deadline_rules_unified_insert ON paliad.deadline_rules_unified;
DROP TRIGGER IF EXISTS deadline_rules_unified_update ON paliad.deadline_rules_unified;
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_insert_trigger();
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_update_trigger();
-- Recreate paliad.deadline_rules from snapshot.
CREATE TABLE paliad.deadline_rules AS TABLE paliad.deadline_rules_pre_140;
-- Re-add the PK constraint (CREATE TABLE AS doesn't carry constraints).
ALTER TABLE paliad.deadline_rules ADD PRIMARY KEY (id);
-- Re-point the FKs back to deadline_rules.
ALTER TABLE paliad.appointments
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
ALTER TABLE paliad.appointments
ADD CONSTRAINT appointments_deadline_rule_id_fkey
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.deadline_rules(id);
ALTER TABLE paliad.deadline_rule_backfill_orphans
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
ALTER TABLE paliad.deadline_rule_backfill_orphans
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.deadline_rules(id);
-- Re-add deadlines.rule_id from the snapshot's data (via sequencing_rule_id
-- which inherited deadline_rules.id during mig 136).
ALTER TABLE paliad.deadlines ADD COLUMN rule_id uuid;
UPDATE paliad.deadlines SET rule_id = sequencing_rule_id WHERE sequencing_rule_id IS NOT NULL;
ALTER TABLE paliad.deadlines
ADD CONSTRAINT fristen_rule_id_fkey
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id);

View File

@@ -0,0 +1,334 @@
-- 140_drop_deadline_rules — Slice B.4 destructive drop (t-paliad-305 / m/paliad#93)
--
-- HARD STOPS:
-- * Audit-first: snapshot paliad.deadline_rules → paliad.deadline_rules_pre_140
-- in the SAME TRANSACTION as the DROP, per m's snapshot policy
-- (precedent migs 091/093/095/098). The whole .up.sql runs inside a
-- single transaction because the migration runner wraps it; if any
-- statement fails, the snapshot CREATE TABLE rolls back with the
-- destructive DROP.
-- * No data loss: paliad.deadline_rules has been a write-side shadow
-- since B.3 (B.2 dual-write keeps sequencing_rules + procedural_events
-- + legal_sources current). Drift verified clean before this slice
-- (deadline_rules=231, sequencing_rules=231, 0 mismatches across
-- counts/FKs/lifecycle/is_active).
--
-- What this migration does:
-- 1. Snapshot deadline_rules → deadline_rules_pre_140 (preserves audit
-- trail of the table's final state for forensic + revert paths).
-- 2. Final reconciliation: catch any deadlines whose
-- sequencing_rule_id/procedural_event_id columns drifted from the
-- legacy rule_id (no live drift today — defensive).
-- 3. Drop the audit trigger on deadline_rules (it can't fire on a
-- gone table; the trigger function itself stays for the historical
-- paliad.deadline_rule_audit reads).
-- 4. Re-point FKs that currently target deadline_rules.id over to
-- sequencing_rules.id. The id values are identical (sequencing_rules
-- inherited deadline_rules.id during mig 136 backfill), so no data
-- migration is needed — just the constraint swap. Affects:
-- - paliad.appointments.deadline_rule_id
-- - paliad.deadline_rule_backfill_orphans.resolved_rule_id
-- 5. Drop paliad.deadlines.rule_id column. Per design §5.4 step 16:
-- "DROP COLUMN paliad.deadlines.rule_id (keep rule_code +
-- custom_rule_text as the human-readable denormalized columns —
-- they're the safety net for orphaned deadlines per t-paliad-258)."
-- The new sequencing_rule_id + procedural_event_id columns from
-- mig 136 are the FK back-links from B.4 forward.
-- 6. DROP TABLE paliad.deadline_rules.
-- 7. INSTEAD OF triggers on paliad.deadline_rules_unified that route
-- INSERTs/UPDATEs to the underlying sr+pe+ls tables. Lets the
-- RuleEditorService keep its existing SQL shape (one INSERT, one
-- UPDATE per write method) with only a table-name swap. The
-- triggers project the legacy column shape back to the three new
-- tables exactly as the dual-write helper did in B.2.
--
-- Down: best-effort restore from the snapshot. The original triggers,
-- indexes, and FKs are NOT recreated — operator must replay historical
-- migrations 078/079/091/095/098/122 to bring the table back to a
-- working shape. The down path is for catastrophic recovery, not casual
-- revert.
-- ---------------------------------------------------------------
-- 1. Snapshot — must precede the destructive ops (same TX).
-- ---------------------------------------------------------------
CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_140 IS
'Snapshot of paliad.deadline_rules taken in mig 140 (Slice B.4, '
't-paliad-305) before the destructive DROP. Mirrors precedent '
'pre_091/093/095/098. Read-only forensic + revert source.';
-- ---------------------------------------------------------------
-- 2. Final reconciliation — should be a no-op (drift was 0 going
-- into this slice). Belt-and-braces against a write that snuck
-- in between drift-check and this migration.
-- ---------------------------------------------------------------
UPDATE paliad.deadlines d
SET sequencing_rule_id = d.rule_id,
procedural_event_id = sr.procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.id = d.rule_id
AND d.rule_id IS NOT NULL
AND (d.sequencing_rule_id IS DISTINCT FROM d.rule_id
OR d.procedural_event_id IS DISTINCT FROM sr.procedural_event_id);
-- ---------------------------------------------------------------
-- 3. Drop the deadline_rules audit trigger. The trigger function
-- (paliad.deadline_rule_audit_trigger) stays defined for any
-- historical references; mig 079 created it.
-- ---------------------------------------------------------------
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
-- ---------------------------------------------------------------
-- 4. Re-point FKs from deadline_rules → sequencing_rules.
-- ---------------------------------------------------------------
ALTER TABLE paliad.appointments
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
ALTER TABLE paliad.appointments
ADD CONSTRAINT appointments_deadline_rule_id_fkey
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.sequencing_rules(id);
ALTER TABLE paliad.deadline_rule_backfill_orphans
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
ALTER TABLE paliad.deadline_rule_backfill_orphans
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.sequencing_rules(id);
-- Drop the deadlines→deadline_rules FK before we drop the column.
ALTER TABLE paliad.deadlines
DROP CONSTRAINT IF EXISTS fristen_rule_id_fkey;
-- ---------------------------------------------------------------
-- 5. Drop paliad.deadlines.rule_id (column + remaining indexes).
-- ---------------------------------------------------------------
ALTER TABLE paliad.deadlines
DROP COLUMN IF EXISTS rule_id;
-- ---------------------------------------------------------------
-- 6. DROP TABLE paliad.deadline_rules. Now that:
-- - dependent FKs are re-pointed to sequencing_rules,
-- - the audit trigger is dropped,
-- - deadlines.rule_id is gone,
-- nothing references the table anymore. The self-FKs
-- (deadline_rules.parent_id, .draft_of) drop with the table.
-- ---------------------------------------------------------------
DROP TABLE paliad.deadline_rules;
-- ---------------------------------------------------------------
-- 7. INSTEAD OF triggers on the view — routes writes to sr+pe+ls.
-- ---------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_insert_trigger()
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
DECLARE
v_legal_source_id uuid;
v_pe_id uuid;
v_code text;
BEGIN
-- legal_sources upsert (no-op if NEW.legal_source is NULL)
IF NEW.legal_source IS NOT NULL THEN
INSERT INTO paliad.legal_sources (citation, jurisdiction)
VALUES (NEW.legal_source,
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
ON CONFLICT (citation) DO NOTHING;
SELECT id INTO v_legal_source_id
FROM paliad.legal_sources
WHERE citation = NEW.legal_source;
END IF;
-- Mint synthetic code when submission_code is NULL — same recipe
-- as mig 136 + B.2 dual-write helper. Stays byte-identical.
v_code := COALESCE(NEW.submission_code,
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
-- procedural_events upsert. ON CONFLICT (code) deliberately leaves
-- lifecycle_state / published_at / is_active alone — those track
-- the procedural-event concept's own lifecycle, not the inserting
-- sequencing-rule's lifecycle (e.g. a CloneAsDraft of a published
-- rule creates a draft sr that shares the published PE; the PE
-- should stay 'published'). Identity columns DO update so an
-- admin editing a draft's name still flips the lawyer-visible
-- label (1:1 today; revisit when 1:N becomes a real pattern).
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)
VALUES
(v_code, NEW.name, NEW.name_en, NEW.description, NEW.event_type,
NEW.primary_party, v_legal_source_id, NEW.concept_id,
COALESCE(NEW.lifecycle_state, 'draft'), NEW.published_at,
COALESCE(NEW.is_active, true))
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
name_en = EXCLUDED.name_en,
description = EXCLUDED.description,
event_kind = EXCLUDED.event_kind,
primary_party_default = EXCLUDED.primary_party_default,
legal_source_id = EXCLUDED.legal_source_id,
concept_id = EXCLUDED.concept_id,
-- lifecycle_state / published_at / is_active deliberately omitted
updated_at = now()
RETURNING id INTO v_pe_id;
-- sequencing_rules insert. id is the caller-supplied NEW.id so
-- existing FK back-links (deadlines.sequencing_rule_id) resolve.
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)
VALUES
(NEW.id, v_pe_id, NEW.proceeding_type_id, NEW.parent_id, NEW.trigger_event_id,
COALESCE(NEW.duration_value, 0), COALESCE(NEW.duration_unit, 'months'),
COALESCE(NEW.timing, 'after'),
NEW.alt_duration_value, NEW.alt_duration_unit, NEW.alt_rule_code, NEW.anchor_alt,
NEW.combine_op, NEW.condition_expr, NEW.primary_party,
COALESCE(NEW.sequence_order, 0),
COALESCE(NEW.is_spawn, false), NEW.spawn_label, NEW.spawn_proceeding_type_id,
COALESCE(NEW.is_bilateral, false), COALESCE(NEW.is_court_set, false),
COALESCE(NEW.priority, 'mandatory'),
NEW.rule_code, NEW.rule_codes, NEW.deadline_notes, NEW.deadline_notes_en,
NEW.choices_offered, NEW.applies_to_target,
COALESCE(NEW.lifecycle_state, 'draft'), NEW.draft_of,
NEW.published_at, COALESCE(NEW.is_active, true),
COALESCE(NEW.created_at, now()), COALESCE(NEW.updated_at, now()));
RETURN NEW;
END $fn$;
CREATE TRIGGER deadline_rules_unified_insert
INSTEAD OF INSERT ON paliad.deadline_rules_unified
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_insert_trigger();
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_update_trigger()
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
DECLARE
v_legal_source_id uuid;
v_code text;
BEGIN
-- legal_sources upsert (only if NEW.legal_source is non-NULL).
-- A change FROM non-NULL TO NULL clears legal_source_id on the
-- procedural_event below — same shape as mig 136 / B.2 behaviour.
IF NEW.legal_source IS NOT NULL THEN
INSERT INTO paliad.legal_sources (citation, jurisdiction)
VALUES (NEW.legal_source,
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
ON CONFLICT (citation) DO NOTHING;
SELECT id INTO v_legal_source_id
FROM paliad.legal_sources
WHERE citation = NEW.legal_source;
END IF;
v_code := COALESCE(NEW.submission_code,
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
-- Update procedural_events keyed by the existing PE link on
-- sequencing_rules. lifecycle_state / published_at / is_active on
-- PE are NOT mirrored from the per-sequencing-rule UPDATE — see
-- the INSERT trigger comment for the rationale (a draft sr that
-- shares its PE with a published peer must not flip the PE to
-- draft). Identity columns DO mirror so editing name/code from
-- the admin UI continues to reach the lawyer-visible label.
UPDATE paliad.procedural_events
SET code = v_code,
name = NEW.name,
name_en = NEW.name_en,
description = NEW.description,
event_kind = NEW.event_type,
primary_party_default = NEW.primary_party,
legal_source_id = v_legal_source_id,
concept_id = NEW.concept_id,
updated_at = now()
WHERE id = (SELECT procedural_event_id
FROM paliad.sequencing_rules
WHERE id = NEW.id);
-- Update sequencing_rules (1:1 by id).
UPDATE paliad.sequencing_rules
SET proceeding_type_id = NEW.proceeding_type_id,
parent_id = NEW.parent_id,
trigger_event_id = NEW.trigger_event_id,
duration_value = NEW.duration_value,
duration_unit = NEW.duration_unit,
timing = NEW.timing,
alt_duration_value = NEW.alt_duration_value,
alt_duration_unit = NEW.alt_duration_unit,
alt_rule_code = NEW.alt_rule_code,
anchor_alt = NEW.anchor_alt,
combine_op = NEW.combine_op,
condition_expr = NEW.condition_expr,
primary_party = NEW.primary_party,
sequence_order = NEW.sequence_order,
is_spawn = NEW.is_spawn,
spawn_label = NEW.spawn_label,
spawn_proceeding_type_id = NEW.spawn_proceeding_type_id,
is_bilateral = NEW.is_bilateral,
is_court_set = NEW.is_court_set,
priority = NEW.priority,
rule_code = NEW.rule_code,
rule_codes = NEW.rule_codes,
deadline_notes = NEW.deadline_notes,
deadline_notes_en = NEW.deadline_notes_en,
choices_offered = NEW.choices_offered,
applies_to_target = NEW.applies_to_target,
lifecycle_state = NEW.lifecycle_state,
draft_of = NEW.draft_of,
published_at = NEW.published_at,
is_active = NEW.is_active,
updated_at = now()
WHERE id = NEW.id;
RETURN NEW;
END $fn$;
CREATE TRIGGER deadline_rules_unified_update
INSTEAD OF UPDATE ON paliad.deadline_rules_unified
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_update_trigger();
-- ---------------------------------------------------------------
-- 8. POST assertions.
-- ---------------------------------------------------------------
DO $$
DECLARE
v_snapshot_count int;
v_view_count int;
v_dr_table_exists int;
v_rule_id_col int;
BEGIN
SELECT COUNT(*) INTO v_snapshot_count FROM paliad.deadline_rules_pre_140;
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
IF v_snapshot_count <> v_view_count THEN
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot has % rows, view has % rows — drift between final state and snapshot',
v_snapshot_count, v_view_count;
END IF;
SELECT COUNT(*) INTO v_dr_table_exists
FROM information_schema.tables
WHERE table_schema = 'paliad' AND table_name = 'deadline_rules';
IF v_dr_table_exists > 0 THEN
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadline_rules table still exists after DROP';
END IF;
SELECT COUNT(*) INTO v_rule_id_col
FROM information_schema.columns
WHERE table_schema = 'paliad' AND table_name = 'deadlines' AND column_name = 'rule_id';
IF v_rule_id_col > 0 THEN
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadlines.rule_id column still exists after DROP';
END IF;
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, view=% rows, INSTEAD OF triggers active',
v_snapshot_count, v_view_count;
END $$;

View File

@@ -0,0 +1,13 @@
-- 145_scenarios — DOWN
--
-- Reverses mig 145. Drops the FK on paliad.projects, the table, the
-- trigger function, and the RLS policies (CASCADE on table drop kills
-- policies). Any data in paliad.scenarios is lost on down.
ALTER TABLE paliad.projects
DROP COLUMN IF EXISTS active_scenario_id;
DROP TRIGGER IF EXISTS scenarios_touch_updated_at_trg ON paliad.scenarios;
DROP FUNCTION IF EXISTS paliad.scenarios_touch_updated_at();
DROP TABLE IF EXISTS paliad.scenarios CASCADE;

View File

@@ -0,0 +1,170 @@
-- 145_scenarios — Slice D, m/paliad#124 §5 (revised)
--
-- Creates paliad.scenarios + paliad.projects.active_scenario_id FK.
-- A scenario is a named composition of existing proceedings + flags
-- + per-card choices + anchor dates the user can switch between for
-- a project (project_id NOT NULL) OR save as an abstract template on
-- /tools/verfahrensablauf (project_id IS NULL).
--
-- m's 2026-05-26 picks (AskUserQuestion round, doc commit 6e58595):
-- Q1: composition shape → primary+spawned (v1); multi-proceeding
-- peer compose is the v2 goal. spec.jsonb
-- architected for N entries from day 1.
-- Q2: scope → per-project + abstract.
-- Q3: trigger dates → per-anchor overrides over one base date.
-- Q4: storage → NEW paliad.scenarios table with jsonb
-- spec (NOT a project_event_choices column
-- extension).
--
-- "users should not add their own rules" (m, t-paliad-301) — scenarios
-- compose existing rules, never author new ones. spec.proceedings[*].code
-- must resolve to an existing active paliad.proceeding_types row;
-- spec.proceedings[*].anchor_overrides keys must resolve to existing
-- submission_codes. Validation happens at the application layer
-- (ScenarioService.validateSpec) — not in DB CHECK constraints (too
-- expensive to express in pure SQL).
--
-- Migration number: 145. Coordination check 2026-05-26 17:38: curie's
-- B.2-B.6 migrations land in the 139-143 range. 144 reserved as buffer.
-- 145 is the next safe claim.
--
-- ADDITIVE ONLY: CREATE TABLE, ALTER ADD COLUMN, indexes, RLS policies.
-- Down drops everything. No backfill (zero existing scenarios on day 1).
--
-- See docs/design-litigation-planner-2026-05-26.md §5 + §18.4 for the
-- design.
-- ---------------------------------------------------------------
-- 1. The scenarios table
-- ---------------------------------------------------------------
CREATE TABLE paliad.scenarios (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- project_id NULL = abstract scenario (saved Verfahrensablauf
-- template, no Akte). project_id NOT NULL = scenario attached to
-- a real Akte.
project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
name text NOT NULL,
description text NULL,
-- spec carries the full composition. Shape documented in the
-- design doc §5; the application validates structure before write.
spec jsonb NOT NULL,
created_by uuid NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Within a single project, scenario names are unique. Abstract
-- scenarios are unique per (created_by, name) so two users can
-- each keep a "with_ccr" template without colliding. NULLS NOT
-- DISTINCT means a single user can have one "name" per
-- (project_id, created_by) tuple, where NULL project_id +
-- NULL created_by is a single global namespace (used only by
-- seed / system scenarios — none today).
CONSTRAINT scenarios_unique_per_scope
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name),
-- Non-empty name.
CONSTRAINT scenarios_name_nonempty CHECK (char_length(name) > 0),
-- Non-empty spec — at least an object. The application checks
-- structure (version, proceedings[], base_trigger_date format).
CONSTRAINT scenarios_spec_object CHECK (jsonb_typeof(spec) = 'object')
);
CREATE INDEX scenarios_project_id_idx
ON paliad.scenarios(project_id) WHERE project_id IS NOT NULL;
CREATE INDEX scenarios_abstract_user_idx
ON paliad.scenarios(created_by) WHERE project_id IS NULL;
COMMENT ON TABLE paliad.scenarios IS
'Named compositions of existing proceedings + flags + per-card '
'choices + anchor dates. project_id NULL = abstract template; '
'project_id NOT NULL = attached to an Akte. Design: '
'docs/design-litigation-planner-2026-05-26.md §5. (Slice D)';
COMMENT ON COLUMN paliad.scenarios.spec IS
'jsonb composition spec. Shape: {version: int, base_trigger_date: '
'ISO date, proceedings: [{code, role, flags[], per_card_choices, '
'anchor_overrides, skip_rules[]}, ...]}. Validated at write-time '
'by ScenarioService.validateSpec.';
-- ---------------------------------------------------------------
-- 2. paliad.projects.active_scenario_id FK
--
-- NULL = use today's ad-hoc per-card choice state from
-- paliad.project_event_choices (pre-scenario behaviour preserved).
-- Non-NULL = the project's current SmartTimeline / Akte-Fristenrechner
-- render reads from this scenario's spec instead.
-- ---------------------------------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN active_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
COMMENT ON COLUMN paliad.projects.active_scenario_id IS
'FK to paliad.scenarios. NULL = read choices from '
'paliad.project_event_choices (legacy). Non-NULL = read from the '
'pointed scenario.spec.';
-- ---------------------------------------------------------------
-- 3. RLS — mirror paliad.project_event_choices's pattern (mig 129).
--
-- Project-scoped scenarios (project_id NOT NULL) inherit team visibility
-- via paliad.can_see_project. Abstract scenarios (project_id IS NULL)
-- are private to created_by — only the author can read / write them.
-- ---------------------------------------------------------------
ALTER TABLE paliad.scenarios ENABLE ROW LEVEL SECURITY;
-- Project-scoped: team visibility.
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
CREATE POLICY scenarios_project_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
-- Abstract: owner-only.
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NULL AND created_by = auth.uid());
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NULL AND created_by = auth.uid())
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
-- ---------------------------------------------------------------
-- 4. updated_at trigger (mirrors other paliad tables that carry
-- updated_at — keep it in lockstep with row mutations).
-- ---------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.scenarios_touch_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER scenarios_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenarios
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
-- ---------------------------------------------------------------
-- 5. Informational NOTICE — schema-only migration, zero rows added.
-- ---------------------------------------------------------------
DO $$
BEGIN
RAISE NOTICE '[mig 145] paliad.scenarios created (0 rows; awaits API usage)';
RAISE NOTICE '[mig 145] paliad.projects.active_scenario_id added (all rows NULL initially)';
END $$;

View File

@@ -120,6 +120,11 @@ type Services struct {
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
// per project or as abstract templates. Nil when DATABASE_URL is
// unset; the /api/scenarios routes return 503 in that case.
Scenario *services.ScenarioService
// Paliadin is wired when DATABASE_URL is set. The concrete backend
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
// (remote → mRiver via SSH) or local tmux availability. Stays nil
@@ -184,6 +189,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
}
}
@@ -446,6 +452,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
// per project or as abstract templates on /tools/verfahrensablauf.
protected.HandleFunc("GET /api/scenarios", handleScenariosList)
protected.HandleFunc("GET /api/scenarios/{id}", handleScenarioGet)
protected.HandleFunc("POST /api/scenarios", handleScenarioCreate)
protected.HandleFunc("PATCH /api/scenarios/{id}", handleScenarioPatch)
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)

View File

@@ -71,6 +71,9 @@ type dbServices struct {
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService
// Slice D — named scenario compositions (m/paliad#124 §5).
scenario *services.ScenarioService
}
var dbSvc *dbServices

View File

@@ -0,0 +1,216 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// Slice D (m/paliad#124 §5, mig 145) — REST endpoints for paliad.scenarios.
//
// Routes (registered in handlers.go):
//
// GET /api/scenarios?project=<id> — list project's scenarios
// GET /api/scenarios?abstract=true — list caller's abstract scenarios
// GET /api/scenarios/{id} — fetch one
// POST /api/scenarios — create
// PATCH /api/scenarios/{id} — partial update
// PUT /api/projects/{id}/active-scenario — set/clear active scenario
// DELETE /api/scenarios/{id} — remove
//
// All endpoints require auth; visibility is enforced by
// ScenarioService.requireProjectVisible / requireVisible.
func requireScenarioService(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.scenario == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Szenarien sind vorübergehend nicht verfügbar (keine Datenbank).",
})
return false
}
return true
}
// scenarioErrorToStatus maps service errors to HTTP statuses. Mirrors
// the patterns in projects.go and event_choices.go.
func scenarioErrorToStatus(err error) (int, string) {
switch {
case errors.Is(err, lp.ErrUnknownScenario), errors.Is(err, services.ErrScenarioNotVisible):
return http.StatusNotFound, "Szenario nicht gefunden"
case errors.Is(err, services.ErrInvalidInput), errors.Is(err, lp.ErrInvalidScenario), errors.Is(err, lp.ErrScenarioNoPrimary):
return http.StatusBadRequest, err.Error()
}
return http.StatusInternalServerError, err.Error()
}
// handleScenariosList — GET /api/scenarios?project=<uuid> OR ?abstract=true.
func handleScenariosList(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
abstract := r.URL.Query().Get("abstract") == "true"
projectStr := r.URL.Query().Get("project")
switch {
case abstract:
out, err := dbSvc.scenario.ListAbstractForUser(r.Context(), uid)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
case projectStr != "":
pid, err := uuid.Parse(projectStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
return
}
out, err := dbSvc.scenario.ListForProject(r.Context(), uid, pid)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
default:
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "?project=<uuid> oder ?abstract=true erforderlich",
})
}
}
// handleScenarioGet — GET /api/scenarios/{id}.
func handleScenarioGet(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
out, err := dbSvc.scenario.Get(r.Context(), uid, id)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
}
// handleScenarioCreate — POST /api/scenarios.
func handleScenarioCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenario.Create(r.Context(), uid, input)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleScenarioPatch — PATCH /api/scenarios/{id}.
func handleScenarioPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
var input services.PatchScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenario.Patch(r.Context(), uid, id, input)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
}
// handleScenarioDelete — DELETE /api/scenarios/{id}.
func handleScenarioDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
if err := dbSvc.scenario.Delete(r.Context(), uid, id); err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleSetActiveScenario — PUT /api/projects/{id}/active-scenario.
// Body: {"scenario_id": "<uuid>"} or {"scenario_id": null} to clear.
func handleSetActiveScenario(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
pid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
return
}
var body struct {
ScenarioID *uuid.UUID `json:"scenario_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
if err := dbSvc.scenario.SetActive(r.Context(), uid, pid, body.ScenarioID); err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -200,7 +200,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
pt.code AS proceeding_code,
pt.name AS proceeding_name,
pt.name_en AS proceeding_name_en
FROM paliad.deadline_rules dr
FROM paliad.deadline_rules_unified dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE dr.is_active = true
AND dr.lifecycle_state = 'published'

View File

@@ -264,7 +264,14 @@ type Deadline struct {
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
Source string `db:"source" json:"source"`
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
// Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column
// dropped; the back-link now lives on `sequencing_rule_id` (FK to
// paliad.sequencing_rules). Same UUID values (sequencing_rules.id
// inherited deadline_rules.id during mig 136 backfill), so internal
// Go references to `RuleID` continue to carry the same semantic
// pointer. The JSON name stays `rule_id` for frontend backward-compat
// — B.5 will rename if/when frontend is updated.
RuleID *uuid.UUID `db:"sequencing_rule_id" json:"rule_id,omitempty"`
// RuleCode is the legal citation ("RoP.023", "R.151") attached at
// save time — see migration 032. Free text by design; survives
// changes to paliad.deadline_rules and accepts citations from

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
@@ -53,13 +55,13 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) (
if proceedingTypeID != nil {
err = s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID)
} else {
err = s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`)
}
@@ -98,7 +100,7 @@ func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Contex
}
query, args, err := sqlx.In(
`SELECT dr.id AS rule_id, j.event_type_id
FROM paliad.deadline_rules dr
FROM paliad.deadline_rules_unified dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concept_event_types j
ON j.concept_id = dr.concept_id
@@ -150,7 +152,7 @@ func (s *DeadlineRuleService) GetRuleTree(ctx context.Context, proceedingTypeCod
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID); err != nil {
return nil, fmt.Errorf("list rules for %q: %w", proceedingTypeCode, err)
@@ -173,10 +175,10 @@ func (s *DeadlineRuleService) GetFullTimeline(ctx context.Context, proceedingTyp
var rules []models.DeadlineRule
err := s.db.SelectContext(ctx, &rules, `
WITH RECURSIVE tree AS (
SELECT * FROM paliad.deadline_rules
SELECT * FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true
UNION ALL
SELECT dr.* FROM paliad.deadline_rules dr
SELECT dr.* FROM paliad.deadline_rules_unified dr
JOIN tree t ON dr.parent_id = t.id
WHERE dr.is_active = true
)
@@ -194,7 +196,7 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
}
query, args, err := sqlx.In(
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids)
if err != nil {
@@ -262,7 +264,7 @@ func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEve
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE trigger_event_id = $1
AND is_active = true
ORDER BY sequence_order`, triggerEventID); err != nil {
@@ -290,7 +292,7 @@ func (s *DeadlineRuleService) ListByProceedingTypeIDs(ctx context.Context, ids [
}
query, args, err := sqlx.In(
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id IN (?)
AND is_active = true
ORDER BY proceeding_type_id, sequence_order`, ids)
@@ -325,7 +327,7 @@ func (s *DeadlineRuleService) ListByConcept(ctx context.Context, conceptID uuid.
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE concept_id = $1
AND is_active = true
ORDER BY proceeding_type_id NULLS LAST, sequence_order`, conceptID); err != nil {

View File

@@ -65,8 +65,13 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
return NewPendingApprovalError(rid, role)
}
// Slice B.4 (mig 140, t-paliad-305): rule_id column dropped from
// paliad.deadlines. sequencing_rule_id holds the same UUID and is the
// FK to paliad.sequencing_rules. SELECT-column lists below pull
// sequencing_rule_id into the Deadline.RuleID field (db tag adjusted in
// internal/models/models.go).
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
warning_date, source, sequencing_rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at`
@@ -272,7 +277,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
ar.requester_kind AS requester_kind
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
LEFT JOIN paliad.deadline_rules r ON r.id = f.rule_id
LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.sequencing_rule_id
LEFT JOIN paliad.approval_requests ar ON ar.id = f.pending_request_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY f.due_date ASC, f.created_at DESC`
@@ -539,7 +544,11 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
if input.RuleID != nil && input.CustomRuleText != nil {
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
}
appendSet("rule_id", input.RuleID)
// Slice B.4 (t-paliad-305): rule_id column dropped; the FK
// back-link now lives on sequencing_rule_id. Same UUID value.
// The procedural_event_id mirror is derived in
// syncDeadlineDualLinks below after the primary UPDATE lands.
appendSet("sequencing_rule_id", input.RuleID)
var customText *string
if input.CustomRuleText != nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
@@ -585,6 +594,16 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update deadline: %w", err)
}
// Slice B.4 (mig 140, t-paliad-305): rule_id column gone;
// sequencing_rule_id holds the back-link. When the patch updated
// it (auto/custom swap from t-paliad-258), mirror the FK onto
// procedural_event_id so the joined view continues to resolve.
// Idempotent: no-op when sequencing_rule_id is unchanged.
if input.RuleSet {
if err := syncDeadlineProceduralEventID(ctx, tx, deadlineID); err != nil {
return nil, err
}
}
}
if input.EventTypeIDs != nil && s.eventTypes != nil {

View File

@@ -0,0 +1,50 @@
// Slice B.4 retirement of B.2 dual-write (t-paliad-305 / m/paliad#93).
//
// Mig 140 dropped paliad.deadline_rules and installed INSTEAD OF
// triggers on paliad.deadline_rules_unified that route writes to
// procedural_events + sequencing_rules + legal_sources. The legacy
// dual-write helper (syncDualWriteFromDeadlineRule) and the drift-check
// loop (CheckDualWriteDrift / StartDualWriteDriftCheckLoop) reference
// paliad.deadline_rules, which no longer exists — they would crash on
// first call if kept.
//
// Survivor: syncDeadlineProceduralEventID — keeps paliad.deadlines's
// new procedural_event_id column in sync with sequencing_rule_id after
// any UPDATE that touched the latter. Still useful as a "derive from
// canonical pointer" helper.
//
// The DualWriteDriftReport struct + HasDrift method are retired with
// the loop they served.
package services
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// syncDeadlineProceduralEventID mirrors paliad.deadlines.sequencing_rule_id
// onto procedural_event_id. Call this within an open transaction AFTER
// any UPDATE that mutates paliad.deadlines.sequencing_rule_id (today's
// callers: DeadlineService.Update on the RuleSet branch, and the
// RuleEditorService orphan-resolve path which sets both columns in one
// statement so doesn't need this helper).
//
// Idempotent: NULL sequencing_rule_id collapses procedural_event_id to
// NULL via the subquery returning NULL. Slice B.4 (t-paliad-305).
func syncDeadlineProceduralEventID(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error {
if _, err := tx.ExecContext(ctx, `
UPDATE paliad.deadlines d
SET procedural_event_id = (
SELECT sr.procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.id = d.sequencing_rule_id
)
WHERE d.id = $1`, deadlineID); err != nil {
return fmt.Errorf("sync deadline procedural_event_id for %s: %w", deadlineID, err)
}
return nil
}

View File

@@ -0,0 +1,297 @@
// Slice B.2 dual-write tests (t-paliad-305 / m/paliad#93).
//
// Asserts the parallel projection — paliad.procedural_events +
// paliad.sequencing_rules + paliad.legal_sources — stays in lock-step
// with paliad.deadline_rules through the full RuleEditorService
// lifecycle. Skipped when TEST_DATABASE_URL is unset.
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestDualWrite_RuleEditorLifecycle walks Create → UpdateDraft →
// CloneAsDraft → Publish → Archive → Restore on RuleEditorService and
// after each operation asserts that paliad.sequencing_rules has the
// 1:1 mirror, paliad.procedural_events carries the projected identity,
// and paliad.legal_sources carries the citation.
func TestDualWrite_RuleEditorLifecycle(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
rules := NewDeadlineRuleService(pool)
svc := NewRuleEditorService(pool, rules)
cleanup := func() {
pool.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason', 'slice b.2 test cleanup', true)`)
// Order matters: sequencing_rules → procedural_events → legal_sources
// (FK direction). deadline_rules cleanup last because mig 079 audit
// trigger captures the DELETE.
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
SELECT id FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'
)`)
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
WHERE code LIKE 'sliceb2.%' OR code LIKE 'null.sliceb2%'`)
pool.ExecContext(ctx, `DELETE FROM paliad.legal_sources
WHERE citation LIKE 'SLICEB2.%'`)
pool.ExecContext(ctx,
`DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'`)
pool.ExecContext(ctx,
`DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_TEST_PT'`)
}
cleanup()
defer cleanup()
var ptID int
if err := pool.GetContext(ctx, &ptID, `
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
VALUES ('SLICEB2_TEST_PT', 'Slice B.2 Test PT', 'Slice B.2 Test PT', 'fristenrechner', 'UPC', true)
RETURNING id`); err != nil {
t.Fatalf("seed proceeding_type: %v", err)
}
subCode := "sliceb2.create"
legalSrc := "SLICEB2.PatG.1"
// 1. Create — assert the parallel rows land.
created, err := svc.Create(ctx, CreateRuleInput{
Name: "SLICEB2_TEST_create",
NameEN: "SLICEB2_TEST_create_EN",
ProceedingTypeID: &ptID,
SubmissionCode: &subCode,
LegalSource: &legalSrc,
DurationValue: 30,
DurationUnit: "days",
Priority: "mandatory",
}, "B.2 dual-write create test")
if err != nil {
t.Fatalf("Create: %v", err)
}
// legal_sources should now carry SLICEB2.PatG.1
var lsCount int
if err := pool.GetContext(ctx, &lsCount,
`SELECT COUNT(*) FROM paliad.legal_sources WHERE citation = $1`, legalSrc); err != nil {
t.Fatalf("query legal_sources: %v", err)
}
if lsCount != 1 {
t.Errorf("legal_sources after Create: got %d, want 1 for citation %q", lsCount, legalSrc)
}
// procedural_events should carry the submission_code
var peName, peLifecycle string
if err := pool.GetContext(ctx, &peName,
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query procedural_events name: %v", err)
}
if peName != "SLICEB2_TEST_create" {
t.Errorf("procedural_events.name after Create: got %q, want %q", peName, "SLICEB2_TEST_create")
}
if err := pool.GetContext(ctx, &peLifecycle,
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query procedural_events lifecycle: %v", err)
}
if peLifecycle != "draft" {
t.Errorf("procedural_events.lifecycle_state after Create: got %q, want %q", peLifecycle, "draft")
}
// sequencing_rules should have id = created.id and link to PE
var srCount, srMatchPE int
if err := pool.GetContext(ctx, &srCount,
`SELECT COUNT(*) FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("query sequencing_rules count: %v", err)
}
if srCount != 1 {
t.Errorf("sequencing_rules row after Create: got %d, want 1 for id %s", srCount, created.ID)
}
if err := pool.GetContext(ctx, &srMatchPE, `
SELECT COUNT(*) FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.id = $1 AND pe.code = $2`, created.ID, subCode); err != nil {
t.Fatalf("query sr→pe join: %v", err)
}
if srMatchPE != 1 {
t.Errorf("sequencing_rules.procedural_event_id after Create: got %d join hits, want 1", srMatchPE)
}
// 2. UpdateDraft — change name + legal_source. Assert propagation.
newName := "SLICEB2_TEST_updated"
newLegal := "SLICEB2.ZPO.2"
_, err = svc.UpdateDraft(ctx, created.ID, RulePatch{
Name: &newName,
LegalSource: &newLegal,
}, "B.2 dual-write update test")
if err != nil {
t.Fatalf("UpdateDraft: %v", err)
}
var afterName string
if err := pool.GetContext(ctx, &afterName,
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query pe.name post-update: %v", err)
}
if afterName != newName {
t.Errorf("procedural_events.name after UpdateDraft: got %q, want %q", afterName, newName)
}
// New citation must appear in legal_sources, and procedural_events.legal_source_id
// must point at it (idempotent UPSERT — the old SLICEB2.PatG.1 row stays).
var pePointsAtNewLegal int
if err := pool.GetContext(ctx, &pePointsAtNewLegal, `
SELECT COUNT(*) FROM paliad.procedural_events pe
JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
WHERE pe.code = $1 AND ls.citation = $2`, subCode, newLegal); err != nil {
t.Fatalf("query pe→ls join: %v", err)
}
if pePointsAtNewLegal != 1 {
t.Errorf("procedural_events.legal_source_id after UpdateDraft: got %d hits, want 1", pePointsAtNewLegal)
}
// 3. Publish — flip to published. Assert lifecycle mirror.
_, err = svc.Publish(ctx, created.ID, "B.2 dual-write publish test")
if err != nil {
t.Fatalf("Publish: %v", err)
}
var srLifecycle, peLifecycleAfterPub string
if err := pool.GetContext(ctx, &srLifecycle,
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("query sr.lifecycle: %v", err)
}
if srLifecycle != "published" {
t.Errorf("sequencing_rules.lifecycle_state after Publish: got %q, want %q", srLifecycle, "published")
}
if err := pool.GetContext(ctx, &peLifecycleAfterPub,
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query pe.lifecycle post-publish: %v", err)
}
if peLifecycleAfterPub != "published" {
t.Errorf("procedural_events.lifecycle_state after Publish: got %q, want %q", peLifecycleAfterPub, "published")
}
// 4. Archive — flip to archived. Assert mirror.
_, err = svc.Archive(ctx, created.ID, "B.2 dual-write archive test")
if err != nil {
t.Fatalf("Archive: %v", err)
}
var srLifecycleArchived string
if err := pool.GetContext(ctx, &srLifecycleArchived,
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("query sr.lifecycle post-archive: %v", err)
}
if srLifecycleArchived != "archived" {
t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived")
}
// Slice B.4 (mig 140, t-paliad-305): the legacy paliad.deadline_rules
// table is gone and so is CheckDualWriteDrift — there's no parallel
// side to compare against. The INSTEAD OF triggers on the view
// guarantee parity by construction (single TX fan-out from one
// SQL write to three target tables).
}
// TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule
// created with submission_code=NULL gets a synthetic 'null.<8hex>'
// procedural_events row matching mig 136's mint expression — so a new
// draft without a code participates in the dual-write contract without
// colliding with any code-bearing rule.
func TestDualWrite_SyntheticCodeForNullSubmission(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
rules := NewDeadlineRuleService(pool)
svc := NewRuleEditorService(pool, rules)
cleanup := func() {
pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice b.2 null-code cleanup', true)`)
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
SELECT id FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
)`)
// Synthetic PE rows are keyed off the rule's uuid; delete by name reference.
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
WHERE code IN (
SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
)`)
pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'`)
pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_NC_PT'`)
}
cleanup()
defer cleanup()
var ptID int
if err := pool.GetContext(ctx, &ptID, `
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
VALUES ('SLICEB2_NC_PT', 'NC PT', 'NC PT', 'fristenrechner', 'UPC', true)
RETURNING id`); err != nil {
t.Fatalf("seed proceeding_type: %v", err)
}
created, err := svc.Create(ctx, CreateRuleInput{
Name: "SLICEB2_TEST_nullcode",
NameEN: "SLICEB2_TEST_nullcode_EN",
ProceedingTypeID: &ptID,
// SubmissionCode intentionally NIL → tests the synthetic-code branch.
DurationValue: 5,
DurationUnit: "days",
Priority: "mandatory",
}, "B.2 dual-write null-code test")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Compute the expected synthetic code in the same way mig 136 / the
// dual-write helper do — keep the expression in lock-step with the
// SQL via this Go-side mirror.
var expectedCode string
if err := pool.GetContext(ctx, &expectedCode,
`SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
FROM paliad.deadline_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("compute expected synthetic code: %v", err)
}
var actualCode string
if err := pool.GetContext(ctx, &actualCode, `
SELECT pe.code
FROM paliad.procedural_events pe
JOIN paliad.sequencing_rules sr ON sr.procedural_event_id = pe.id
WHERE sr.id = $1`, created.ID); err != nil {
t.Fatalf("query procedural_events via sequencing_rules: %v", err)
}
if actualCode != expectedCode {
t.Errorf("synthetic code mismatch: got %q, want %q", actualCode, expectedCode)
}
if len(actualCode) != len("null.")+8 {
t.Errorf("synthetic code length: got %d, want 13 (null.+8hex)", len(actualCode))
}
}

View File

@@ -168,7 +168,7 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
COALESCE(timing, 'after') AS timing,
deadline_notes, deadline_notes_en, alt_duration_value, alt_duration_unit,
combine_op, rule_codes
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE trigger_event_id = $1 AND is_active = true
ORDER BY sequence_order`, triggerEventID)
if err != nil {

View File

@@ -1138,7 +1138,7 @@ func personalSheetQueries(actorID uuid.UUID) []sheetQuery {
},
{
SheetName: "ref__deadline_rules",
SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`,
SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`,
},
{
SheetName: "ref__deadline_concepts",
@@ -1518,7 +1518,7 @@ SELECT 'partner_unit_default'::text AS source,
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
@@ -1563,7 +1563,7 @@ SELECT 'partner_unit_default'::text AS source,
func orgSheetQueries() []sheetQuery {
return []sheetQuery{
// --- entity sheets (alphabetical) ---
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, binding_id`},
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
@@ -1621,7 +1621,7 @@ func orgSheetQueries() []sheetQuery {
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},

View File

@@ -5,6 +5,8 @@ import (
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
@@ -13,6 +15,7 @@ import (
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// FristenrechnerService renders the Paliad public Fristenrechner's
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
// is a thin adapter: the compute engine + types live in
@@ -166,7 +169,7 @@ func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*model
var rule models.DeadlineRule
err := c.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id = $1 AND is_active = true`, ruleID)
if errors.Is(err, sql.ErrNoRows) {
return nil, lp.ErrUnknownRule
@@ -197,7 +200,7 @@ func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, subm
var rule models.DeadlineRule
err = c.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
pt.ID, submissionCode)
if errors.Is(err, sql.ErrNoRows) {
@@ -223,6 +226,351 @@ func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64)
return c.rules.LoadTriggerEventsByIDs(ctx, ids)
}
// LookupEvents queries paliad.deadline_rules for rules matching the
// requested axes, then walks the parent_id graph in Go to honour the
// requested depth. Slice B2 (m/paliad#124 §18.2).
//
// Filter axes apply at the SQL layer:
// - Jurisdiction: WHERE paliad.proceeding_types.jurisdiction = $X
// - ProceedingTypeID: WHERE deadline_rules.proceeding_type_id = $X
// - Party: WHERE deadline_rules.primary_party = $X
// - EventCategoryID: EXISTS subquery on
// paliad.event_category_concepts joined via concept_id
// - AppealTarget: WHERE $X = ANY(deadline_rules.applies_to_target)
//
// Depth is applied post-fetch: for EventLookupDepthNext, anchor rules
// (matched directly) are returned at depth=1 + their immediate
// children (parent_id IN matched-set) at depth=2. For
// EventLookupDepthAllFollowing, the parent_id walk continues
// recursively. The walk stays within the per-proceeding rule set
// (cross-proceeding spawn following is handled by the engine, not by
// LookupEvents).
//
// "published + active" gate: lifecycle_state='published' AND
// is_active=true (matches LoadProceeding's WHERE clause).
func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
// Validate axis values up front; unknown values fall through as
// "no filter on this axis" so a stale frontend chip doesn't
// silently drop the entire result set.
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 = ""
}
// Build the WHERE clause progressively. Each axis adds a $N
// placeholder + appends to the args slice.
where := []string{
"dr.is_active = true",
"dr.lifecycle_state = 'published'",
"pt.is_active = true",
}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
where = append(where, fmt.Sprintf(clause, len(args)))
}
if jurisdiction != "" {
add("pt.jurisdiction = $%d", jurisdiction)
}
if axes.ProceedingTypeID != nil {
add("dr.proceeding_type_id = $%d", *axes.ProceedingTypeID)
}
if party != "" {
add("dr.primary_party = $%d", party)
}
if axes.EventCategoryID != nil {
// Junction-table EXISTS: the rule's concept_id must appear in
// paliad.event_category_concepts with the matching
// event_category_id.
add(`EXISTS (
SELECT 1 FROM paliad.event_category_concepts ecc
WHERE ecc.event_category_id = $%d
AND ecc.concept_id = dr.concept_id
)`, *axes.EventCategoryID)
}
if appealTarget != "" {
add("$%d = ANY(dr.applies_to_target)", appealTarget)
}
query := `
SELECT ` + ruleColumns + `,
pt.id AS pt_id, pt.code AS pt_code, pt.name AS pt_name,
pt.name_en AS pt_name_en, pt.description AS pt_description,
pt.jurisdiction AS pt_jurisdiction, pt.category AS pt_category,
pt.default_color AS pt_default_color, pt.sort_order AS pt_sort_order,
pt.is_active AS pt_is_active,
pt.trigger_event_label_de AS pt_trigger_event_label_de,
pt.trigger_event_label_en AS pt_trigger_event_label_en,
pt.appeal_target AS pt_appeal_target
FROM paliad.deadline_rules_unified dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE ` + strings.Join(where, "\n AND ") + `
ORDER BY dr.proceeding_type_id, dr.sequence_order`
var rows []lookupEventsRow
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("lookup events: %w", err)
}
if len(rows) == 0 {
return []lp.EventMatch{}, nil
}
// matchedIDs is the set of rule IDs that satisfied the axes (the
// "anchor" matches at depth=1). For EventLookupDepthNext we add
// their direct children. For EventLookupDepthAllFollowing we walk
// the parent_id chain transitively.
matchedIDs := make(map[uuid.UUID]bool, len(rows))
anchorMatch := make(map[uuid.UUID]bool, len(rows))
rowByID := make(map[uuid.UUID]lookupEventsRow, len(rows))
for _, r := range rows {
matchedIDs[r.ID] = true
anchorMatch[r.ID] = true
rowByID[r.ID] = r
}
// For depth control we need the full per-proceeding rule corpus
// (so we can find children whose parent_id ∈ matchedIDs even when
// those children don't match the axes themselves). Skip this when
// depth is empty (treated as "anchors only" — undocumented but
// useful as a degenerate case).
expandFromCorpus := func(corpus []models.DeadlineRule, joinedFor map[int]lookupEventsRow) {
// We loop until no new descendants are added (transitive
// closure under parent_id ∈ matchedIDs). EventLookupDepthNext
// stops after one pass; AllFollowing iterates to fixpoint.
for {
grew := false
for _, r := range corpus {
if r.ParentID == nil {
continue
}
if !matchedIDs[*r.ParentID] {
continue
}
if matchedIDs[r.ID] {
continue
}
matchedIDs[r.ID] = true
j := joinedFor[*r.ProceedingTypeID]
rowByID[r.ID] = lookupEventsRow{
DeadlineRule: r,
PTID: j.PTID,
PTCode: j.PTCode,
PTName: j.PTName,
PTNameEN: j.PTNameEN,
PTDescription: j.PTDescription,
PTJurisdiction: j.PTJurisdiction,
PTCategory: j.PTCategory,
PTDefaultColor: j.PTDefaultColor,
PTSortOrder: j.PTSortOrder,
PTIsActive: j.PTIsActive,
PTTriggerEventLabelDE: j.PTTriggerEventLabelDE,
PTTriggerEventLabelEN: j.PTTriggerEventLabelEN,
PTAppealTarget: j.PTAppealTarget,
}
grew = true
}
if !grew || depth == lp.EventLookupDepthNext {
break
}
}
}
if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
// Load the proceeding-scoped corpus for every proceeding_type
// that appeared in the anchor set. The walk needs full
// visibility into each proceeding's rule tree so it can
// resolve parent_id chains.
procIDs := make(map[int]struct{})
joinedFor := make(map[int]lookupEventsRow)
for _, r := range rows {
procIDs[r.PTID] = struct{}{}
if _, ok := joinedFor[r.PTID]; !ok {
joinedFor[r.PTID] = r
}
}
for ptID := range procIDs {
corpus, err := c.rules.List(ctx, &ptID)
if err != nil {
return nil, fmt.Errorf("lookup events: load proceeding %d corpus: %w", ptID, err)
}
expandFromCorpus(corpus, joinedFor)
}
}
// depths[id] = sequence-depth from the closest anchor ancestor.
// Anchors are depth=1; their direct children are depth=2; etc.
depths := computeDepths(rowByID, anchorMatch)
// Compose the result slice ordered by (PTID, sequence_order).
type withKey struct {
match lp.EventMatch
key int64
}
items := make([]withKey, 0, len(matchedIDs))
for id := range matchedIDs {
r := rowByID[id]
var parentRuleID *uuid.UUID
if r.ParentID != nil && matchedIDs[*r.ParentID] {
p := *r.ParentID
parentRuleID = &p
}
items = append(items, withKey{
match: lp.EventMatch{
Rule: r.DeadlineRule,
ProceedingType: lp.ProceedingType{
ID: r.PTID,
Code: r.PTCode,
Name: r.PTName,
NameEN: r.PTNameEN,
Description: r.PTDescription,
Jurisdiction: r.PTJurisdiction,
Category: r.PTCategory,
DefaultColor: r.PTDefaultColor,
SortOrder: r.PTSortOrder,
IsActive: r.PTIsActive,
TriggerEventLabelDE: r.PTTriggerEventLabelDE,
TriggerEventLabelEN: r.PTTriggerEventLabelEN,
AppealTarget: r.PTAppealTarget,
},
Priority: r.Priority,
DepthFromAnchor: depths[id],
ParentRuleID: parentRuleID,
},
key: int64(r.PTID)*1_000_000 + int64(r.SequenceOrder),
})
}
sort.Slice(items, func(a, b int) bool { return items[a].key < items[b].key })
out := make([]lp.EventMatch, len(items))
for i, it := range items {
out[i] = it.match
}
return out, nil
}
// lookupEventsRow is the joined SELECT shape for LookupEvents — one
// deadline_rules row plus its proceeding_types parent columns. Kept
// at package scope so computeDepths can reference it.
type lookupEventsRow struct {
models.DeadlineRule
PTID int `db:"pt_id"`
PTCode string `db:"pt_code"`
PTName string `db:"pt_name"`
PTNameEN string `db:"pt_name_en"`
PTDescription *string `db:"pt_description"`
PTJurisdiction *string `db:"pt_jurisdiction"`
PTCategory *string `db:"pt_category"`
PTDefaultColor string `db:"pt_default_color"`
PTSortOrder int `db:"pt_sort_order"`
PTIsActive bool `db:"pt_is_active"`
PTTriggerEventLabelDE *string `db:"pt_trigger_event_label_de"`
PTTriggerEventLabelEN *string `db:"pt_trigger_event_label_en"`
PTAppealTarget *string `db:"pt_appeal_target"`
}
// computeDepths walks from each rule up the parent_id chain until it
// hits an anchor match (or runs out). The depth of the anchor is 1;
// each step away adds one. Rules whose entire chain has no anchor
// (defensive — shouldn't happen given the expand-from-corpus walk
// only adds children of matched parents) get depth=1.
//
// Iteration-bounded by the corpus size to prevent infinite loops on
// hypothetical parent_id cycles (mig 134 + the schema CHECKs already
// preclude cycles, but the bound is cheap insurance).
func computeDepths(
rowByID map[uuid.UUID]lookupEventsRow,
anchors map[uuid.UUID]bool,
) map[uuid.UUID]int {
depths := make(map[uuid.UUID]int, len(rowByID))
for id := range rowByID {
if anchors[id] {
depths[id] = 1
continue
}
// Walk parents until we find an anchor or run out.
d := 1
cur := id
maxIter := len(rowByID) + 1
for i := 0; i < maxIter; i++ {
r := rowByID[cur]
if r.ParentID == nil {
break
}
d++
cur = *r.ParentID
if anchors[cur] {
break
}
}
depths[id] = d
}
return depths
}
// LoadScenarios lists scenarios visible to the caller (Slice D,
// m/paliad#124 §5, mig 145). RLS on paliad.scenarios enforces:
// project-scoped rows require paliad.can_see_project(project_id);
// abstract rows require created_by = auth.uid(). The filter narrows
// the SELECT (project_id-bound, abstract-for-user, or all).
func (c *paliadCatalog) LoadScenarios(ctx context.Context, filter lp.ScenarioFilter) ([]lp.Scenario, error) {
where := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
where = append(where, fmt.Sprintf(clause, len(args)))
}
if filter.ProjectID != nil {
add("project_id = $%d", *filter.ProjectID)
}
if filter.AbstractForUser != nil {
where = append(where, "project_id IS NULL")
add("created_by = $%d", *filter.AbstractForUser)
}
query := `SELECT id, project_id, name, description, spec,
created_by, created_at, updated_at
FROM paliad.scenarios`
if len(where) > 0 {
query += " WHERE " + strings.Join(where, " AND ")
}
query += " ORDER BY created_at DESC"
var rows []lp.Scenario
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("load scenarios: %w", err)
}
return rows, nil
}
// MatchScenario returns the scenario with the given id, or
// lp.ErrUnknownScenario if not visible / not found. RLS gates
// visibility; a not-found result could mean "doesn't exist" OR
// "exists but you can't see it" — either way the caller treats it
// as unknown.
func (c *paliadCatalog) MatchScenario(ctx context.Context, id uuid.UUID) (*lp.Scenario, error) {
var s lp.Scenario
err := c.rules.db.GetContext(ctx, &s,
`SELECT id, project_id, name, description, spec,
created_by, created_at, updated_at
FROM paliad.scenarios
WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, lp.ErrUnknownScenario
}
if err != nil {
return nil, fmt.Errorf("match scenario %q: %w", id, err)
}
return &s, nil
}
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
var _ lp.Catalog = (*paliadCatalog)(nil)

View File

@@ -0,0 +1,219 @@
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// TestLookupEvents covers the multi-axis catalog query API from Slice
// B2 (m/paliad#124 §18.2). Skipped when TEST_DATABASE_URL is unset,
// mirroring TestCalculateRule.
//
// Cases:
// - jurisdiction=UPC, depth=all-following → every active+published
// UPC rule, anchor depth=1 for all (no parent_id outside the
// filtered set lights up depth>1 because the entire UPC subset is
// a single anchor cohort).
// - proceeding_type_id (upc.inf.cfi) + party=defendant + depth=next
// → defendant rules in upc.inf.cfi at depth=1 + direct children
// of those at depth=2.
// - unknown jurisdiction value → silently ignored, no filter applied.
// - empty axes → all rules (no filter on any axis).
func TestLookupEvents(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
rules := NewDeadlineRuleService(pool)
catalog := &paliadCatalog{rules: rules}
t.Run("jurisdiction=UPC, all-following returns the UPC corpus", func(t *testing.T) {
matches, err := catalog.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")
}
// Every match must be a UPC rule.
for _, m := range matches {
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
t.Errorf("non-UPC row leaked into UPC-axis query: code=%s jurisdiction=%v",
m.ProceedingType.Code, m.ProceedingType.Jurisdiction)
}
if m.DepthFromAnchor < 1 {
t.Errorf("depth=%d for rule %s, want >= 1", m.DepthFromAnchor, m.Rule.ID)
}
}
})
t.Run("party=defendant scopes to defendant rules", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
Party: "defendant",
}, lp.EventLookupDepthNext)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
if len(matches) == 0 {
t.Fatal("expected at least one defendant rule across the UPC corpus")
}
// Anchor matches (depth=1) must be primary_party=defendant.
// Depth=2 children appear under EventLookupDepthNext only as
// expansion from anchors — they may carry any party.
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue
}
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
t.Errorf("anchor row %s (depth=1) is not defendant: %v",
m.Rule.Name, m.Rule.PrimaryParty)
}
}
})
t.Run("unknown jurisdiction value silently falls through", func(t *testing.T) {
matchesAll, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{},
lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents (all): %v", err)
}
matchesUnknown, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "XX-not-a-real-jurisdiction",
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents (unknown): %v", err)
}
if len(matchesAll) != len(matchesUnknown) {
t.Errorf("unknown jurisdiction should fall through to no-filter; got %d vs all-axes %d",
len(matchesUnknown), len(matchesAll))
}
})
t.Run("appeal_target=endentscheidung returns upc.apl merits rules", func(t *testing.T) {
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
Jurisdiction: "UPC",
AppealTarget: lp.AppealTargetEndentscheidung,
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
// Should hit the 7 rules under the unified upc.apl that
// carry applies_to_target={endentscheidung} (Slice B1 mig 134).
if len(matches) == 0 {
t.Fatal("expected upc.apl endentscheidung rules after B1 mig")
}
for _, m := range matches {
if m.DepthFromAnchor != 1 {
continue // children of anchors may be from other targets
}
found := false
for _, t := range m.Rule.AppliesToTarget {
if t == lp.AppealTargetEndentscheidung {
found = true
break
}
}
if !found {
t.Errorf("anchor row %s missing endentscheidung 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=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,
}, lp.EventLookupDepthAllFollowing)
if err != nil {
t.Fatalf("LookupEvents: %v", err)
}
// 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

@@ -1767,7 +1767,7 @@ func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
ptID, code)
if errors.Is(err, sql.ErrNoRows) {
@@ -1784,7 +1784,7 @@ func (s *ProjectionService) lookupRuleByID(ctx context.Context, id uuid.UUID) (*
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("lookup rule by id: %w", err)
@@ -1805,7 +1805,7 @@ func (s *ProjectionService) parentHasAnchoredActual(ctx context.Context, project
err := s.db.GetContext(ctx, &count, `
SELECT COUNT(*) FROM (
SELECT 1 FROM paliad.deadlines
WHERE project_id = $1 AND rule_id = $2
WHERE project_id = $1 AND sequencing_rule_id = $2
AND (completed_at IS NOT NULL
OR status = 'completed'
OR source = 'anchor')
@@ -1843,7 +1843,7 @@ func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, pr
var existingID uuid.UUID
err := s.db.GetContext(ctx, &existingID,
`SELECT id FROM paliad.deadlines
WHERE project_id = $1 AND rule_id = $2
WHERE project_id = $1 AND sequencing_rule_id = $2
ORDER BY created_at ASC
LIMIT 1`, projectID, rule.ID)
switch {

View File

@@ -117,7 +117,7 @@ func (s *RuleEditorService) ListOrphans(ctx context.Context) ([]Orphan, error) {
}
if err := s.db.SelectContext(ctx, &cs, `
SELECT id, rule_code, name, name_en
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id = ANY($1::uuid[])`, pq.Array(uuidStrs)); err != nil {
return nil, fmt.Errorf("list orphan candidate rules: %w", err)
}
@@ -212,14 +212,21 @@ func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUI
}
now := time.Now().UTC()
// Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column
// dropped. Back-link lives on sequencing_rule_id (same UUIDs as
// before — sr.id inherited dr.id at mig 136 backfill).
// procedural_event_id is derived from the same sequencing_rules row.
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET rule_id = $1,
updated_at = $2
WHERE id = $3`,
`UPDATE paliad.deadlines d
SET sequencing_rule_id = $1,
procedural_event_id = (SELECT procedural_event_id
FROM paliad.sequencing_rules
WHERE id = $1),
updated_at = $2
WHERE d.id = $3`,
ruleID, now, oc.DeadlineID,
); err != nil {
return fmt.Errorf("set deadline rule_id: %w", err)
return fmt.Errorf("set deadline sequencing_rule_id: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rule_backfill_orphans

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
}
@@ -167,7 +178,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
// here writes the live shape only — priority + condition_expr
// + is_court_set are the new gates.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
`INSERT INTO paliad.deadline_rules_unified
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
@@ -198,6 +209,12 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
return nil, fmt.Errorf("insert rule: %w", err)
}
// Slice B.4 (mig 140, t-paliad-305): write routes through the
// INSTEAD OF triggers on paliad.deadline_rules_unified, which fan
// out into legal_sources + procedural_events + sequencing_rules.
// No Go-side mirror call needed — the INSERT above already landed
// the parallel rows.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create: %w", err)
}
@@ -220,6 +237,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
@@ -247,11 +277,13 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
args = append(args, time.Now().UTC())
args = append(args, id)
q := fmt.Sprintf(
`UPDATE paliad.deadline_rules SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
`UPDATE paliad.deadline_rules_unified SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
strings.Join(sets, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return nil, fmt.Errorf("update rule draft: %w", err)
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF trigger handles the
// new-table writes — the UPDATE above is already fan-out.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update: %w", err)
}
@@ -285,7 +317,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
newID := uuid.New()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
`INSERT INTO paliad.deadline_rules_unified
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
name, name_en, description, primary_party, event_type,
duration_value, duration_unit, timing,
@@ -306,12 +338,16 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
is_active,
'draft', $2, NULL,
now(), now()
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE id = $2`,
newID, id,
); err != nil {
return nil, fmt.Errorf("clone rule as draft: %w", err)
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF INSERT trigger
// mints the synthetic 'null.<8hex>' code when submission_code is
// NULL (matching mig 136 + the legacy dual-write helper's
// expression).
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit clone: %w", err)
}
@@ -345,7 +381,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = 'published',
published_at = $1,
updated_at = $1
@@ -358,7 +394,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
// Archive the peer this draft was cloned from, if any.
if current.DraftOf != nil {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = 'archived',
updated_at = $1
WHERE id = $2 AND lifecycle_state = 'published'`,
@@ -368,6 +404,10 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
}
}
// Slice B.4 (mig 140, t-paliad-305): both UPDATEs above route via
// the INSTEAD OF UPDATE trigger, which mirrors the lifecycle flip
// onto procedural_events + sequencing_rules in the same TX.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit publish: %w", err)
}
@@ -415,7 +455,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
// timestamp helps audit reads ("when was this rule first live?").
if target == "published" {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = $1,
published_at = COALESCE(published_at, $2),
updated_at = $2
@@ -426,7 +466,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
}
} else {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules
`UPDATE paliad.deadline_rules_unified
SET lifecycle_state = $1, updated_at = $2
WHERE id = $3`,
target, now, id,
@@ -435,6 +475,9 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
}
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF UPDATE trigger
// mirrors the lifecycle flip onto sr + pe.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit flip: %w", err)
}
@@ -574,7 +617,7 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
where = "WHERE " + strings.Join(conds, " AND ")
}
query := `SELECT ` + ruleColumns + `
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
` + where + `
ORDER BY proceeding_type_id NULLS LAST, sequence_order
LIMIT ` + addArg(f.Limit) + ` OFFSET ` + addArg(f.Offset)
@@ -594,7 +637,7 @@ func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.
func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
var r models.DeadlineRule
err := s.db.GetContext(ctx, &r,
`SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE id = $1`, id)
`SELECT `+ruleColumns+` FROM paliad.deadline_rules_unified WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRuleNotFound
}
@@ -653,7 +696,7 @@ func (s *RuleEditorService) validateSpawnNoCycle(ctx context.Context, ruleID *uu
visited[current] = true
var nexts []sql.NullInt64
q := `SELECT DISTINCT spawn_proceeding_type_id::bigint
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE proceeding_type_id = $1
AND is_spawn = true
AND spawn_proceeding_type_id IS NOT NULL

View File

@@ -0,0 +1,347 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// ScenarioService reads + writes paliad.scenarios — named compositions
// of existing proceedings + flags + per-card choices + anchor dates,
// switchable per project or saved as abstract templates on
// /tools/verfahrensablauf. Slice D, m/paliad#124 §5, mig 145.
//
// Visibility:
// - Project-scoped scenarios (project_id NOT NULL): require
// can_see_project on the bound project (mirrors
// EventChoiceService.requireProjectVisible).
// - Abstract scenarios (project_id IS NULL): owner-only. Only
// created_by can read / mutate.
//
// The service applies these checks in application code; paliad.scenarios
// also has RLS policies (mig 145) as defense-in-depth for callers that
// connect through Supabase Auth's auth.uid() session.
type ScenarioService struct {
db *sqlx.DB
projects *ProjectService
rules *DeadlineRuleService
}
// NewScenarioService wires the service to its dependencies.
func NewScenarioService(db *sqlx.DB, projects *ProjectService, rules *DeadlineRuleService) *ScenarioService {
return &ScenarioService{db: db, projects: projects, rules: rules}
}
// Sentinel errors. Mirrors EventChoiceService + the lp package errors
// so handlers can map cleanly to HTTP statuses.
var (
ErrScenarioNotVisible = errors.New("scenario not visible to caller")
)
// CreateScenarioInput is the payload for POST /api/scenarios. project_id
// nil = abstract scenario (saved Verfahrensablauf template).
type CreateScenarioInput struct {
ProjectID *uuid.UUID `json:"project_id,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Spec json.RawMessage `json:"spec"`
}
// Create inserts a new scenario after validating the spec.
func (s *ScenarioService) Create(ctx context.Context, userID uuid.UUID, input CreateScenarioInput) (*lp.Scenario, error) {
if input.Name == "" {
return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
}
if err := s.validateSpec(ctx, input.Spec); err != nil {
return nil, err
}
if input.ProjectID != nil {
if err := s.requireProjectVisible(ctx, userID, *input.ProjectID); err != nil {
return nil, err
}
}
var out lp.Scenario
err := s.db.GetContext(ctx, &out,
`INSERT INTO paliad.scenarios (project_id, name, description, spec, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, name, description, spec, created_by,
created_at, updated_at`,
input.ProjectID, input.Name, input.Description,
[]byte(input.Spec), userID)
if err != nil {
return nil, fmt.Errorf("create scenario: %w", err)
}
return &out, nil
}
// Get returns one scenario by id after a visibility check.
func (s *ScenarioService) Get(ctx context.Context, userID, scenarioID uuid.UUID) (*lp.Scenario, error) {
var sc lp.Scenario
err := s.db.GetContext(ctx, &sc,
`SELECT id, project_id, name, description, spec, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE id = $1`, scenarioID)
if errors.Is(err, sql.ErrNoRows) {
return nil, lp.ErrUnknownScenario
}
if err != nil {
return nil, fmt.Errorf("get scenario: %w", err)
}
if err := s.requireVisible(ctx, userID, &sc); err != nil {
return nil, err
}
return &sc, nil
}
// ListForProject returns scenarios attached to one project, ordered by
// created_at desc.
func (s *ScenarioService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]lp.Scenario, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
out := []lp.Scenario{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, name, description, spec, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE project_id = $1
ORDER BY created_at DESC`, projectID)
if err != nil {
return nil, fmt.Errorf("list scenarios for project: %w", err)
}
return out, nil
}
// ListAbstractForUser returns the calling user's abstract scenarios.
func (s *ScenarioService) ListAbstractForUser(ctx context.Context, userID uuid.UUID) ([]lp.Scenario, error) {
out := []lp.Scenario{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, name, description, spec, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE project_id IS NULL AND created_by = $1
ORDER BY created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("list abstract scenarios: %w", err)
}
return out, nil
}
// PatchScenarioInput is the payload for PATCH /api/scenarios/{id}. Any
// field nil means "don't change". Spec replacement re-runs validation.
type PatchScenarioInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Spec json.RawMessage `json:"spec,omitempty"`
}
// Patch updates one or more scenario fields. Visibility check fires
// first (the caller must already see the scenario to mutate it).
func (s *ScenarioService) Patch(ctx context.Context, userID, scenarioID uuid.UUID, input PatchScenarioInput) (*lp.Scenario, error) {
current, err := s.Get(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
if len(input.Spec) > 0 {
if err := s.validateSpec(ctx, input.Spec); err != nil {
return nil, err
}
}
sets := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf(clause, len(args)))
}
if input.Name != nil {
add("name = $%d", *input.Name)
}
if input.Description != nil {
add("description = $%d", *input.Description)
}
if len(input.Spec) > 0 {
add("spec = $%d", []byte(input.Spec))
}
if len(sets) == 0 {
return current, nil
}
args = append(args, scenarioID)
query := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
WHERE id = $%d
RETURNING id, project_id, name, description, spec, created_by,
created_at, updated_at`, joinSets(sets), len(args))
var out lp.Scenario
if err := s.db.GetContext(ctx, &out, query, args...); err != nil {
return nil, fmt.Errorf("patch scenario: %w", err)
}
return &out, nil
}
// SetActive points a project at one of its scenarios. Pass nil to
// clear (revert to ad-hoc per-card choice state).
func (s *ScenarioService) SetActive(ctx context.Context, userID, projectID uuid.UUID, scenarioID *uuid.UUID) error {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return err
}
if scenarioID != nil {
// Ensure scenario exists + belongs to this project. A scenario
// from a different project (or an abstract one) can't be the
// active scenario on this project.
sc, err := s.Get(ctx, userID, *scenarioID)
if err != nil {
return err
}
if sc.ProjectID == nil || *sc.ProjectID != projectID {
return fmt.Errorf("%w: scenario %s is not attached to project %s",
ErrInvalidInput, *scenarioID, projectID)
}
}
_, err := s.db.ExecContext(ctx,
`UPDATE paliad.projects SET active_scenario_id = $1 WHERE id = $2`,
scenarioID, projectID)
if err != nil {
return fmt.Errorf("set active scenario: %w", err)
}
return nil
}
// Delete removes a scenario. Project's active_scenario_id is cleared
// automatically via the FK's ON DELETE SET NULL.
func (s *ScenarioService) Delete(ctx context.Context, userID, scenarioID uuid.UUID) error {
// Visibility check via Get — also resolves the existence question.
if _, err := s.Get(ctx, userID, scenarioID); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.scenarios WHERE id = $1`, scenarioID); err != nil {
return fmt.Errorf("delete scenario: %w", err)
}
return nil
}
// requireVisible enforces the per-row visibility rule:
// - project_id NOT NULL → caller must see the project
// - project_id IS NULL → caller must be the row's created_by
func (s *ScenarioService) requireVisible(ctx context.Context, userID uuid.UUID, sc *lp.Scenario) error {
if sc.ProjectID != nil {
return s.requireProjectVisible(ctx, userID, *sc.ProjectID)
}
if sc.CreatedBy == nil || *sc.CreatedBy != userID {
return ErrScenarioNotVisible
}
return nil
}
// requireProjectVisible mirrors EventChoiceService.requireProjectVisible
// (visibility via can_see_project). Cheap re-implementation — keeps the
// call-graph small + avoids a cross-service dep.
func (s *ScenarioService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
var visible bool
err := s.db.GetContext(ctx, &visible,
`SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = $1 AND u.global_role = 'global_admin'
) OR EXISTS (
SELECT 1 FROM paliad.projects p
JOIN paliad.project_teams pt ON pt.project_id = ANY(
string_to_array(p.path, '.')::uuid[]
)
WHERE p.id = $2 AND pt.user_id = $1
)`, userID, projectID)
if err != nil {
return fmt.Errorf("check project visibility: %w", err)
}
if !visible {
return ErrScenarioNotVisible
}
return nil
}
// validateSpec checks the jsonb spec is well-formed, has the right
// version, and that every referenced proceeding code + submission code
// resolves to an active row in the live catalog. Surfaces friendly
// errors wrapping ErrInvalidInput so the handler can map to a 400.
func (s *ScenarioService) validateSpec(ctx context.Context, raw json.RawMessage) error {
if len(raw) == 0 {
return fmt.Errorf("%w: spec is required", ErrInvalidInput)
}
parsed, err := lp.ParseSpec(lp.NullableJSON(raw))
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
if _, err := parsed.PrimaryProceeding(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
if parsed.BaseTriggerDate != "" {
if _, err := time.Parse("2006-01-02", parsed.BaseTriggerDate); err != nil {
return fmt.Errorf("%w: base_trigger_date %q is not YYYY-MM-DD", ErrInvalidInput, parsed.BaseTriggerDate)
}
}
for i, p := range parsed.Proceedings {
if p.Code == "" {
return fmt.Errorf("%w: proceedings[%d].code is empty", ErrInvalidInput, i)
}
if p.Role != lp.ScenarioRolePrimary && p.Role != lp.ScenarioRolePeer {
return fmt.Errorf("%w: proceedings[%d].role=%q must be 'primary' or 'peer'",
ErrInvalidInput, i, p.Role)
}
if p.AppealTarget != "" && !lp.IsValidAppealTarget(p.AppealTarget) {
return fmt.Errorf("%w: proceedings[%d].appeal_target=%q not in %v",
ErrInvalidInput, i, p.AppealTarget, lp.AppealTargets)
}
if p.TriggerDateOverride != "" {
if _, err := time.Parse("2006-01-02", p.TriggerDateOverride); err != nil {
return fmt.Errorf("%w: proceedings[%d].trigger_date_override %q is not YYYY-MM-DD",
ErrInvalidInput, i, p.TriggerDateOverride)
}
}
for code, dateStr := range p.AnchorOverrides {
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
return fmt.Errorf("%w: proceedings[%d].anchor_overrides[%q]=%q is not YYYY-MM-DD",
ErrInvalidInput, i, code, dateStr)
}
}
// Resolve code against active proceedings.
var exists bool
if err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true)`,
p.Code); err != nil {
return fmt.Errorf("validate spec proceedings[%d]: %w", i, err)
}
if !exists {
return fmt.Errorf("%w: proceedings[%d].code=%q is not an active proceeding_type",
ErrInvalidInput, i, p.Code)
}
}
return nil
}
// joinSets joins SET clauses with ", ". Tiny utility, kept here to
// avoid cross-package strings.Join indirection.
func joinSets(sets []string) string {
out := ""
for i, s := range sets {
if i > 0 {
out += ", "
}
out += s
}
return out
}
// Suppress unused-import diagnostic when models isn't referenced
// (kept for future shape-evolution; canonical scenario row lives in lp).
var _ = models.NullableJSON(nil)

View File

@@ -243,7 +243,7 @@ func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissio
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules
FROM paliad.deadline_rules_unified
WHERE submission_code = $1
AND lifecycle_state = 'published'
AND is_active = true
@@ -289,12 +289,12 @@ func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID,
var d models.Deadline
err := s.db.GetContext(ctx, &d,
`SELECT id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, status, completed_at,
warning_date, source, sequencing_rule_id, rule_code, status, completed_at,
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at
FROM paliad.deadlines
WHERE project_id = $1
AND rule_id = $2
AND sequencing_rule_id = $2
AND status = 'pending'
ORDER BY due_date ASC
LIMIT 1`, projectID, ruleID)

View File

@@ -0,0 +1,58 @@
package litigationplanner
// AppealRole* are the canonical filer-role slugs used by the unified
// upc.apl Berufung proceeding (t-paliad-307 / m/paliad#136 Bug 1).
//
// Every appeal filing rule carries primary_party='both' in the catalog
// (either party could be the appellant, depending on which side lost
// downstream), so the static primary_party column can't drive
// column-bucketing under a user-perspective `?side=` pick. The
// per-rule appeal role fills that gap: "appellant" rules are filed by
// the Berufungskläger (the party who lost in the lower instance and
// is now appealing); "appellee" rules are filed by the
// Berufungsbeklagter (the party defending the lower-instance
// decision). The mapping is rule-semantic, not data-driven — we know
// from R.224/235 which submission belongs to which side.
const (
AppealRoleAppellant = "appellant"
AppealRoleAppellee = "appellee"
)
// AppealFilerRole returns the appeal-filer role for a submission code
// in the unified upc.apl proceeding. Empty string for codes whose role
// is not statically known (court-issued events, unmapped codes, or
// non-appeal proceedings).
//
// The engine stamps TimelineEntry.AppealRole with this value when
// CalcOptions.AppealTarget is set so the frontend column-bucketer can
// route each "both"-party rule into the correct user-perspective
// column (Berufungskläger vs Berufungsbeklagter) once the user picks
// a side.
//
// Adding a new appeal rule? Add its submission_code to the matching
// branch below. Court-issued events (cost.decision, order.order,
// merits.oral, merits.decision) deliberately stay empty — they route
// to the court column on primary_party='court'.
func AppealFilerRole(submissionCode string) string {
switch submissionCode {
// Appellant filings — Berufungskläger initiates the appeal +
// replies to the cross-appeal.
case "upc.apl.merits.notice",
"upc.apl.merits.grounds",
"upc.apl.merits.cross_a_reply",
"upc.apl.cost.leave_app",
"upc.apl.order.with_leave",
"upc.apl.order.grounds_orders",
"upc.apl.order.discretion",
"upc.apl.order.cross_reply":
return AppealRoleAppellant
// Appellee filings — Berufungsbeklagter responds to the appeal +
// files the cross-appeal.
case "upc.apl.merits.response",
"upc.apl.merits.cross_a",
"upc.apl.order.response_orders",
"upc.apl.order.cross":
return AppealRoleAppellee
}
return ""
}

View File

@@ -0,0 +1,192 @@
package litigationplanner
import (
"context"
"testing"
"github.com/google/uuid"
)
// TestAppealFilerRole pins the rule-semantic mapping that drives
// column-bucketing on the unified upc.apl Berufung timeline
// (t-paliad-307 / m/paliad#136 Bug 1). Every appeal filing rule has
// primary_party='both' in the catalog so the bucketer can't decide
// between Berufungskläger and Berufungsbeklagter columns from
// primary_party alone — the appeal role fills that gap.
func TestAppealFilerRole(t *testing.T) {
cases := []struct {
code string
want string
}{
// Appellant filings (Berufungskläger initiates / replies to cross).
{"upc.apl.merits.notice", AppealRoleAppellant},
{"upc.apl.merits.grounds", AppealRoleAppellant},
{"upc.apl.merits.cross_a_reply", AppealRoleAppellant},
{"upc.apl.cost.leave_app", AppealRoleAppellant},
{"upc.apl.order.with_leave", AppealRoleAppellant},
{"upc.apl.order.grounds_orders", AppealRoleAppellant},
{"upc.apl.order.discretion", AppealRoleAppellant},
{"upc.apl.order.cross_reply", AppealRoleAppellant},
// Appellee filings (Berufungsbeklagter responds + cross-appeals).
{"upc.apl.merits.response", AppealRoleAppellee},
{"upc.apl.merits.cross_a", AppealRoleAppellee},
{"upc.apl.order.response_orders", AppealRoleAppellee},
{"upc.apl.order.cross", AppealRoleAppellee},
// Court-issued events stay empty — they route on party='court'.
{"upc.apl.merits.decision", ""},
{"upc.apl.merits.oral", ""},
{"upc.apl.cost.decision", ""},
{"upc.apl.order.order", ""},
// Unmapped codes are empty (defensive — never silently picks a
// side for a new appeal rule we forgot to map).
{"upc.inf.cfi.soc", ""},
{"", ""},
{"foo.bar", ""},
}
for _, c := range cases {
if got := AppealFilerRole(c.code); got != c.want {
t.Errorf("AppealFilerRole(%q) = %q, want %q", c.code, got, c.want)
}
}
}
// TestCalculate_AppealSyntheticTriggerRow exercises the synthetic root
// row the engine prepends when CalcOptions.AppealTarget is set
// (t-paliad-307 / m/paliad#136 Bug 2). The row carries the
// per-appeal-target label, the trigger date as DueDate, IsRootEvent=
// IsTriggerEvent=true, and party=court. Without the appeal_target
// filter, no synthetic row is emitted (regression guard).
func TestCalculate_AppealSyntheticTriggerRow(t *testing.T) {
ctx := context.Background()
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.apl.unified",
Name: "Berufung",
NameEN: "Appeal",
Jurisdiction: &jurisdiction,
IsActive: true,
}
mkID := func() uuid.UUID {
id, _ := uuid.NewRandom()
return id
}
str := func(s string) *string { return &s }
procIDPtr := &procID
noticeCode := "upc.apl.merits.notice"
groundsCode := "upc.apl.merits.grounds"
rules := []Rule{
{
ID: mkID(),
ProceedingTypeID: procIDPtr,
SubmissionCode: &noticeCode,
Name: "Berufungseinlegung",
NameEN: "Notice of Appeal",
PrimaryParty: str(PrimaryPartyBoth),
DurationValue: 2,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 0,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
},
{
ID: mkID(),
ProceedingTypeID: procIDPtr,
SubmissionCode: &groundsCode,
Name: "Berufungsbegründung",
NameEN: "Statement of Grounds",
PrimaryParty: str(PrimaryPartyBoth),
DurationValue: 4,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 1,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
},
}
cat := &stubCatalog{pt: pt, rules: rules}
t.Run("with appeal_target — synthetic row prepended + appeal_role stamped", func(t *testing.T) {
opts := CalcOptions{AppealTarget: AppealTargetEndentscheidung}
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
if len(timeline.Deadlines) < 3 {
t.Fatalf("expected synthetic row + 2 rules, got %d rows", len(timeline.Deadlines))
}
// Synthetic row first.
first := timeline.Deadlines[0]
if !first.IsTriggerEvent {
t.Errorf("first row IsTriggerEvent=%v, want true", first.IsTriggerEvent)
}
if !first.IsRootEvent {
t.Errorf("first row IsRootEvent=%v, want true", first.IsRootEvent)
}
if first.Name != "Endentscheidung (R.118)" {
t.Errorf("first row Name=%q, want %q", first.Name, "Endentscheidung (R.118)")
}
if first.NameEN != "Final decision (R.118)" {
t.Errorf("first row NameEN=%q, want %q", first.NameEN, "Final decision (R.118)")
}
if first.DueDate != "2026-05-26" {
t.Errorf("first row DueDate=%q, want 2026-05-26", first.DueDate)
}
if first.Party != PrimaryPartyCourt {
t.Errorf("first row Party=%q, want court", first.Party)
}
// Real rules should carry AppealRole.
byCode := map[string]TimelineEntry{}
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
if got := byCode[noticeCode].AppealRole; got != AppealRoleAppellant {
t.Errorf("notice AppealRole=%q, want appellant", got)
}
if got := byCode[groundsCode].AppealRole; got != AppealRoleAppellant {
t.Errorf("grounds AppealRole=%q, want appellant", got)
}
})
t.Run("without appeal_target — no synthetic row, no appeal_role", func(t *testing.T) {
opts := CalcOptions{}
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
for _, d := range timeline.Deadlines {
if d.IsTriggerEvent {
t.Errorf("unexpected synthetic trigger row when appeal_target is unset: %+v", d)
}
if d.AppealRole != "" {
t.Errorf("unexpected AppealRole=%q when appeal_target is unset (rule %q)", d.AppealRole, d.Code)
}
}
})
t.Run("unknown appeal_target — short-circuits to no-op", func(t *testing.T) {
opts := CalcOptions{AppealTarget: "bogus"}
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
// IsValidAppealTarget("bogus") = false, so the engine skips
// both the rule filter AND the synthetic trigger emission.
for _, d := range timeline.Deadlines {
if d.IsTriggerEvent {
t.Errorf("unexpected synthetic trigger row for unknown target: %+v", d)
}
}
})
}

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,328 @@
package litigationplanner
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
)
// Regression test for t-paliad-304 / m/paliad#135.
//
// Reproduces the R.109.1 / R.109.4 anchor bug on upc.inf.cfi:
// - Trigger (Klageerhebung): parent_id=nil, duration=0, !IsCourtSet, sequence_order=0
// - Translation request: parent_id=oral, duration=1mo before, sequence_order=45
// - Interpreter cost: parent_id=oral, duration=2w before, sequence_order=46
// - Oral hearing: parent_id=nil, duration=0, IsCourtSet, sequence_order=50
//
// The "before" children are listed BEFORE the oral hearing in sequence
// order (because chronologically they happen before it). The engine walks
// rules in sequence_order, so when it processes the translation/
// interpreter rows, the oral hearing has not yet been processed →
// courtSet[oral.ID] is not yet set → parentIsCourtSet is false → the
// engine falls back to the trigger date as the base. Result: the timing=
// 'before' arithmetic produces 27.04.2026 (1mo before SoC) instead of
// the conditional-no-date treatment that a court-set parent should
// trigger.
//
// Expected post-fix: translation_request + interpreter_cost render as
// IsConditional (no concrete date) because their parent's date is
// court-set and the proceeding does not yet have an explicit override.
// stubCatalog implements lp.Catalog backed by an in-memory rule slice.
// Only LoadProceeding is needed for the engine path under test; the
// other interface methods return errors so an unintended call surfaces
// immediately.
type stubCatalog struct {
pt ProceedingType
rules []Rule
}
func (s *stubCatalog) LoadProceeding(_ context.Context, code string, _ ProjectHint) (*ProceedingType, []Rule, error) {
if code != s.pt.Code {
return nil, nil, ErrUnknownProceedingType
}
rules := make([]Rule, len(s.rules))
copy(rules, s.rules)
pt := s.pt
return &pt, rules, nil
}
func (s *stubCatalog) LoadProceedingByID(_ context.Context, _ int) (*ProceedingType, error) {
return nil, errors.New("stubCatalog.LoadProceedingByID: not implemented")
}
func (s *stubCatalog) LoadRuleByID(_ context.Context, _ string) (*Rule, error) {
return nil, errors.New("stubCatalog.LoadRuleByID: not implemented")
}
func (s *stubCatalog) LoadRuleByCode(_ context.Context, _, _ string) (*Rule, *ProceedingType, error) {
return nil, nil, errors.New("stubCatalog.LoadRuleByCode: not implemented")
}
func (s *stubCatalog) LoadRulesByTriggerEvent(_ context.Context, _ int64) ([]Rule, error) {
return nil, nil
}
func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[int64]TriggerEvent, error) {
return map[int64]TriggerEvent{}, nil
}
func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) {
return nil, nil
}
func (s *stubCatalog) LoadScenarios(_ context.Context, _ ScenarioFilter) ([]Scenario, error) {
return nil, nil
}
func (s *stubCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*Scenario, error) {
return nil, ErrUnknownScenario
}
// noOpHolidays never adjusts dates — the test fixture doesn't care about
// weekends or holidays, only about which base date the engine resolves.
type noOpHolidays struct{}
func (noOpHolidays) IsNonWorkingDay(_ time.Time, _, _ string) bool { return false }
func (noOpHolidays) AdjustForNonWorkingDays(d time.Time, _, _ string) (time.Time, time.Time, bool) {
return d, d, false
}
func (noOpHolidays) AdjustForNonWorkingDaysBackward(d time.Time, _, _ string) (time.Time, time.Time, bool) {
return d, d, false
}
func (noOpHolidays) AdjustForNonWorkingDaysWithReason(d time.Time, _, _ string) (time.Time, time.Time, bool, *AdjustmentReason) {
return d, d, false, nil
}
type fixedCourts struct{}
func (fixedCourts) CountryRegime(_, _, _ string) (string, string, error) {
return CountryDE, RegimeUPC, nil
}
func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T) {
ctx := context.Background()
// proceeding metadata
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.inf.cfi",
Name: "Verletzungsverfahren",
NameEN: "Infringement",
Jurisdiction: &jurisdiction,
IsActive: true,
}
mkID := func() uuid.UUID {
id, _ := uuid.NewRandom()
return id
}
str := func(s string) *string { return &s }
procIDPtr := &procID
socID := mkID()
oralID := mkID()
transID := mkID()
interpID := mkID()
socCode := "upc.inf.cfi.soc"
oralCode := "upc.inf.cfi.oral"
transCode := "upc.inf.cfi.translation_request"
interpCode := "upc.inf.cfi.interpreter_cost"
rules := []Rule{
{
ID: socID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &socCode,
Name: "Klageerhebung",
NameEN: "Statement of Claim",
PrimaryParty: str("claimant"),
DurationValue: 0,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 0,
IsCourtSet: false,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
},
// Translation request: sequence_order BEFORE the oral hearing.
// Reproduces the real corpus ordering (DB rows 45 < 50).
{
ID: transID,
ProceedingTypeID: procIDPtr,
ParentID: &oralID,
SubmissionCode: &transCode,
Name: "Antrag auf Simultanübersetzung",
NameEN: "Translation request",
PrimaryParty: str("both"),
DurationValue: 1,
DurationUnit: "months",
Timing: str("before"),
SequenceOrder: 45,
IsCourtSet: false,
IsActive: true,
LifecycleState: "published",
Priority: "optional",
},
// Interpreter cost notice: sequence_order BEFORE the oral hearing.
{
ID: interpID,
ProceedingTypeID: procIDPtr,
ParentID: &oralID,
SubmissionCode: &interpCode,
Name: "Mitteilung Dolmetscherkosten",
NameEN: "Interpreter cost notice",
PrimaryParty: str("court"),
DurationValue: 2,
DurationUnit: "weeks",
Timing: str("before"),
SequenceOrder: 46,
IsCourtSet: false,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
},
// Oral hearing: court-set, no calculable date. Listed AFTER its
// "before"-timed children in sequence_order.
{
ID: oralID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &oralCode,
Name: "Mündliche Verhandlung",
NameEN: "Oral hearing",
PrimaryParty: str("court"),
DurationValue: 0,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 50,
IsCourtSet: true,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
},
}
cat := &stubCatalog{pt: pt, rules: rules}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
// The trigger event itself is unambiguous.
if got := byCode[socCode]; got.DueDate != "2026-05-26" || !got.IsRootEvent {
t.Errorf("SoC: DueDate=%q IsRootEvent=%v, want 2026-05-26 + IsRootEvent=true", got.DueDate, got.IsRootEvent)
}
// Oral hearing must surface as IsCourtSet (no date).
oral := byCode[oralCode]
if oral.DueDate != "" || !oral.IsCourtSet {
t.Errorf("oral: DueDate=%q IsCourtSet=%v, want empty + IsCourtSet=true", oral.DueDate, oral.IsCourtSet)
}
// The two "before" children of the court-set oral hearing MUST surface
// as conditional rows (no date, no fabricated arithmetic off the
// trigger date). The buggy behaviour produces 2026-04-27 and 2026-05-12.
trans := byCode[transCode]
if trans.DueDate != "" {
t.Errorf("translation_request: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", trans.DueDate)
}
if !trans.IsConditional && !trans.IsCourtSet {
t.Errorf("translation_request: IsConditional=%v IsCourtSet=%v, want at least one true", trans.IsConditional, trans.IsCourtSet)
}
interp := byCode[interpCode]
if interp.DueDate != "" {
t.Errorf("interpreter_cost: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", interp.DueDate)
}
if !interp.IsConditional && !interp.IsCourtSet {
t.Errorf("interpreter_cost: IsConditional=%v IsCourtSet=%v, want at least one true", interp.IsConditional, interp.IsCourtSet)
}
}
// TestCalculate_BeforeChildOfCourtSetParent_WithOverride pins the
// override semantics: when the user supplies an anchor override for
// the court-set parent, the "before" children should compute against
// that override date instead of remaining conditional.
func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
ctx := context.Background()
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.inf.cfi",
Name: "Verletzungsverfahren",
Jurisdiction: &jurisdiction,
IsActive: true,
}
mkID := func() uuid.UUID {
id, _ := uuid.NewRandom()
return id
}
str := func(s string) *string { return &s }
procIDPtr := &procID
socID := mkID()
oralID := mkID()
transID := mkID()
socCode := "upc.inf.cfi.soc"
oralCode := "upc.inf.cfi.oral"
transCode := "upc.inf.cfi.translation_request"
rules := []Rule{
{
ID: socID, ProceedingTypeID: procIDPtr, ParentID: nil,
SubmissionCode: &socCode, Name: "Klageerhebung", NameEN: "SoC",
PrimaryParty: str("claimant"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
SequenceOrder: 0, IsActive: true, LifecycleState: "published", Priority: "mandatory",
},
{
ID: transID, ProceedingTypeID: procIDPtr, ParentID: &oralID,
SubmissionCode: &transCode, Name: "Antrag auf Simultanübersetzung", NameEN: "Translation request",
PrimaryParty: str("both"), DurationValue: 1, DurationUnit: "months", Timing: str("before"),
SequenceOrder: 45, IsActive: true, LifecycleState: "published", Priority: "optional",
},
{
ID: oralID, ProceedingTypeID: procIDPtr, ParentID: nil,
SubmissionCode: &oralCode, Name: "Mündliche Verhandlung", NameEN: "Oral hearing",
PrimaryParty: str("court"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
SequenceOrder: 50, IsCourtSet: true, IsActive: true, LifecycleState: "published", Priority: "mandatory",
},
}
cat := &stubCatalog{pt: pt, rules: rules}
// User pins the oral hearing to 2026-10-15.
opts := CalcOptions{
AnchorOverrides: map[string]string{
oralCode: "2026-10-15",
},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
if got := byCode[oralCode].DueDate; got != "2026-10-15" {
t.Errorf("oral: DueDate=%q, want 2026-10-15 (user override)", got)
}
// 1 month before 2026-10-15 = 2026-09-15
if got := byCode[transCode].DueDate; got != "2026-09-15" {
t.Errorf("translation_request: DueDate=%q, want 2026-09-15 (1 month before oral override)", got)
}
}

View File

@@ -1,6 +1,10 @@
package litigationplanner
import "context"
import (
"context"
"github.com/google/uuid"
)
// Catalog supplies proceeding-type metadata + rules for the calculator.
//
@@ -46,4 +50,30 @@ type Catalog interface {
// are simply absent (caller treats absence as "no override").
// Empty input returns an empty map without a DB roundtrip.
LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error)
// LookupEvents returns deadline rules matching any subset of the
// requested axes, at the requested sequence depth (Slice B2,
// m/paliad#124 §18.2). Used by the Determinator cascade, the
// scenarios surface (Slice D), and any future "show me events
// matching X" query. Empty result is NOT an error.
//
// Implementations must respect the catalog's "published + active"
// rule gate (rules with lifecycle_state='draft' or is_active=false
// must NEVER appear in the result). Sort order is
// (proceeding_type_id, sequence_order) so the frontend can render
// without re-sorting.
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
// LoadScenarios lists scenarios visible to the caller, narrowed by
// the filter (Slice D, m/paliad#124 §5). Returns an empty slice
// (NOT an error) when no scenarios match. paliad-side impl applies
// RLS (paliad.can_see_project for project-scoped, created_by for
// abstract); snapshot-backed catalogs return an empty list.
LoadScenarios(ctx context.Context, filter ScenarioFilter) ([]Scenario, error)
// MatchScenario returns the scenario with the given id, or
// ErrUnknownScenario if not found / not visible. The engine adapter
// (CalculateFromScenario) calls this to fetch a scenario by id and
// then unpacks its spec via ParseSpec.
MatchScenario(ctx context.Context, id uuid.UUID) (*Scenario, error)
}

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,315 @@
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
}
// LoadScenarios returns an empty slice. The snapshot catalog has no
// scenarios — youpc.org (the consumer today) doesn't carry a project /
// user model. Future snapshot variants could ship demo scenarios, but
// v1 returns nothing.
func (c *SnapshotCatalog) LoadScenarios(_ context.Context, _ lp.ScenarioFilter) ([]lp.Scenario, error) {
return []lp.Scenario{}, nil
}
// MatchScenario always returns ErrUnknownScenario — the snapshot has
// no scenarios to match against.
func (c *SnapshotCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*lp.Scenario, error) {
return nil, lp.ErrUnknownScenario
}
// 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

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/google/uuid"
@@ -183,10 +184,27 @@ func Calculate(
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
}
// Walk the rule list in sequence_order (already sorted by the
// catalog query) and compute each entry, keeping a code→date map so
// RelativeTo / parent_id references resolve to the adjusted
// predecessor date.
// Walk the rule list in TOPOLOGICAL order (parents before children),
// not the raw sequence_order order from the catalog. The catalog
// returns rules sorted by sequence_order, which is the chronological/
// display order. That order is parent-first for the common
// timing='after' case but parent-LAST for timing='before' children
// (e.g. upc.inf.cfi.translation_request at seq=45 vs its parent
// upc.inf.cfi.oral at seq=50 — m/paliad#135). Without topological
// ordering the parent-state checks below (courtSet[parent] /
// computed[parent_code]) read stale empty maps when a child appears
// before its parent, and the engine falls back to the trigger date
// → fabricates dates before the SoC.
//
// Original sequence_order is restored at the end of the walk so the
// wire shape and the timeline view's render order stay identical to
// the legacy behaviour modulo the bug fix.
sequenceIndex := make(map[uuid.UUID]int, len(rules))
for i, r := range rules {
sequenceIndex[r.ID] = i
}
walkRules := topoSortByParentDepth(rules)
computed := make(map[string]time.Time, len(rules))
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]TimelineEntry, 0, len(rules))
@@ -197,7 +215,7 @@ func Calculate(
hiddenCount := 0
appellantContext := make(map[uuid.UUID]string, len(rules))
for _, r := range rules {
for _, r := range walkRules {
// Phase-3 unified gate: evaluate condition_expr (jsonb).
// Suppression semantic preserved: when the gate fires false
// AND no alt_* values exist, the rule is dropped from the
@@ -249,6 +267,10 @@ func Calculate(
appellantContext[r.ID] = ctxVal
}
ruleTiming := ""
if r.Timing != nil {
ruleTiming = *r.Timing
}
d := TimelineEntry{
RuleID: r.ID.String(),
Name: r.Name,
@@ -258,6 +280,9 @@ func Calculate(
AppellantContext: ctxVal,
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
IsHidden: isHidden,
DurationValue: r.DurationValue,
DurationUnit: r.DurationUnit,
Timing: ruleTiming,
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
@@ -547,6 +572,35 @@ func Calculate(
deadlines = append(deadlines, d)
}
// Stamp AppealRole on every entry when an appeal-target filter is
// active so the frontend column-bucketer can route primary_party=
// 'both' rules into the user-perspective columns
// (Berufungskläger vs Berufungsbeklagter). Court events stay empty
// — they route on Party='court' regardless. (t-paliad-307 /
// m/paliad#136 Bug 1)
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
for i := range deadlines {
if deadlines[i].Code == "" {
continue
}
deadlines[i].AppealRole = AppealFilerRole(deadlines[i].Code)
}
}
// Restore sequence_order on the output slice. The compute walk
// re-ordered rules topologically (parent-first) so the parent-state
// checks resolved correctly; the wire shape and the linear timeline
// view both rely on sequence_order being the surface render order.
// (m/paliad#135)
sort.SliceStable(deadlines, func(i, j int) bool {
a, errA := uuid.Parse(deadlines[i].RuleID)
b, errB := uuid.Parse(deadlines[j].RuleID)
if errA != nil || errB != nil {
return false
}
return sequenceIndex[a] < sequenceIndex[b]
})
// t-paliad-296: within consecutive runs of rules sharing the same
// trigger group (parent_id + trigger_event_id), reorder by duration
// ascending so optional events following the same anchor render in
@@ -555,6 +609,31 @@ func Calculate(
// same-group rows. Court-set / conditional rows sort LAST.
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
// Synthetic trigger-event row for appeal timelines (t-paliad-307 /
// m/paliad#136 Bug 2). The decision being appealed (Endentscheidung
// R.118, Kostenentscheidung, Anordnung, …) isn't a rule in the
// upc.apl catalog — it's the anchor the user picked. Lawyers expect
// it to surface as the first row of the timeline so the chain reads
// decision → appeal filings → next decision. Emitted only when an
// appeal_target is in play and the helper returns a non-empty label.
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
nameDE := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de")
nameEN := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en")
if nameDE != "" || nameEN != "" {
trig := TimelineEntry{
Name: nameDE,
NameEN: nameEN,
Party: PrimaryPartyCourt,
Priority: "informational",
DueDate: triggerDateStr,
OriginalDate: triggerDateStr,
IsRootEvent: true,
IsTriggerEvent: true,
}
deadlines = append([]TimelineEntry{trig}, deadlines...)
}
}
resp := &Timeline{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
@@ -571,6 +650,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
@@ -656,6 +750,9 @@ func calculateByTriggerEvent(
OriginalDate: original.Format("2006-01-02"),
WasAdjusted: wasAdj,
AdjustmentReason: reason,
DurationValue: r.DurationValue,
DurationUnit: r.DurationUnit,
Timing: timing,
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
@@ -925,3 +1022,60 @@ func AllFlagsSet(required []string, set map[string]struct{}) bool {
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
return wireFlagsFromPriority(priority)
}
// topoSortByParentDepth returns a copy of `rules` ordered so every rule
// appears after its parent_id ancestor. Ties (rules at the same depth)
// preserve their input order — which the catalog returns in
// sequence_order. Used by Calculate to ensure the parent-state checks
// (courtSet[parent], computed[parent_code]) see populated entries even
// when sequence_order lists a "before"-timed child BEFORE its parent
// (e.g. upc.inf.cfi.translation_request at seq=45 with parent
// upc.inf.cfi.oral at seq=50 — m/paliad#135).
//
// Rules whose parent_id is missing from the rule slice (cross-tree
// references that the per-proceeding filter dropped) are treated as
// depth 0 — they walk in their original sequence position.
//
// The algorithm is depth-via-memoised-recursion. Cycle protection: a
// rule chain that revisits a node is broken at depth 0; production
// data shouldn't contain cycles, but a corrupted catalog mustn't hang
// the calculator.
func topoSortByParentDepth(rules []Rule) []Rule {
byID := make(map[uuid.UUID]Rule, len(rules))
inSlice := make(map[uuid.UUID]bool, len(rules))
for _, r := range rules {
byID[r.ID] = r
inSlice[r.ID] = true
}
depth := make(map[uuid.UUID]int, len(rules))
var resolve func(id uuid.UUID, seen map[uuid.UUID]bool) int
resolve = func(id uuid.UUID, seen map[uuid.UUID]bool) int {
if d, ok := depth[id]; ok {
return d
}
if seen[id] {
depth[id] = 0
return 0
}
seen[id] = true
r, ok := byID[id]
if !ok || r.ParentID == nil || !inSlice[*r.ParentID] {
depth[id] = 0
return 0
}
d := resolve(*r.ParentID, seen) + 1
depth[id] = d
return d
}
for _, r := range rules {
resolve(r.ID, map[uuid.UUID]bool{})
}
out := make([]Rule, len(rules))
copy(out, rules)
sort.SliceStable(out, func(i, j int) bool {
return depth[out[i].ID] < depth[out[j].ID]
})
return out
}

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

@@ -0,0 +1,215 @@
package litigationplanner
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// Slice D scenarios — m/paliad#124 §5 (revised), mig 145.
//
// A Scenario is a named composition of existing proceedings + flags +
// per-card choices + anchor dates. v1 ships with one primary proceeding
// per scenario; the spec.proceedings[] array is architected to absorb
// multi-peer compose (v2) without a schema migration.
//
// "users should not add their own rules" (m, t-paliad-301) — the spec
// references existing rules by submission_code; it never creates new
// ones. ValidateSpec checks every code/submission resolves against the
// current catalog before a save is accepted.
// Scenario is one row of paliad.scenarios. Wire shape doubles as the
// API request/response payload for /api/scenarios.
type Scenario struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
// Spec carries the jsonb composition. Stored raw so we can ship
// shape evolutions without schema churn; ParseSpec gives the
// structured view.
Spec NullableJSON `db:"spec" json:"spec"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ScenarioSpec is the parsed view of Scenario.Spec. v1 = version 1.
// Future shape changes bump the version; ParseSpec rejects unknown
// versions so an old client doesn't silently misread a future-shape
// scenario.
type ScenarioSpec struct {
Version int `json:"version"`
BaseTriggerDate string `json:"base_trigger_date"`
Proceedings []ScenarioProceeding `json:"proceedings"`
}
// ScenarioProceeding is one entry under spec.proceedings[]. v1 honours
// exactly one with role="primary" (additional entries with role="peer"
// are reserved for v2 multi-proceeding compose and silently ignored
// by the engine today).
type ScenarioProceeding struct {
Code string `json:"code"`
Role string `json:"role"` // "primary" | "peer" (v2)
TriggerDateOverride string `json:"trigger_date_override,omitempty"`
Flags []string `json:"flags,omitempty"`
PerCardChoices map[string]ScenarioCardChoice `json:"per_card_choices,omitempty"`
AnchorOverrides map[string]string `json:"anchor_overrides,omitempty"`
SkipRules []string `json:"skip_rules,omitempty"`
AppealTarget string `json:"appeal_target,omitempty"`
}
// ScenarioCardChoice is one entry under
// spec.proceedings[*].per_card_choices. Mirrors the t-paliad-265 choice
// kinds; not every kind is populated on every card.
type ScenarioCardChoice struct {
Appellant string `json:"appellant,omitempty"`
IncludeCCR *bool `json:"include_ccr,omitempty"`
Skip *bool `json:"skip,omitempty"`
}
// Spec version constant.
const ScenarioSpecVersion = 1
// Sentinel errors for scenarios.
var (
ErrUnknownScenario = errors.New("unknown scenario")
ErrInvalidScenario = errors.New("invalid scenario spec")
ErrScenarioNoPrimary = errors.New("scenario spec has no proceeding with role='primary'")
)
// ScenarioRole* are the canonical role slugs for ScenarioProceeding.Role.
const (
ScenarioRolePrimary = "primary"
ScenarioRolePeer = "peer"
)
// ParseSpec decodes Scenario.Spec into a structured ScenarioSpec. Used
// by the engine adapter + the rule-editor preview. Surfaces a friendly
// error wrapping ErrInvalidScenario on malformed JSON / unknown version
// so the handler can map to a 400.
func ParseSpec(raw NullableJSON) (*ScenarioSpec, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%w: spec is empty", ErrInvalidScenario)
}
var s ScenarioSpec
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return nil, fmt.Errorf("%w: decode spec: %v", ErrInvalidScenario, err)
}
if s.Version != ScenarioSpecVersion {
return nil, fmt.Errorf("%w: spec.version=%d, want %d",
ErrInvalidScenario, s.Version, ScenarioSpecVersion)
}
return &s, nil
}
// PrimaryProceeding returns the entry from spec.proceedings[] with
// role="primary". Returns ErrScenarioNoPrimary if absent — every spec
// must carry exactly one primary entry. (Multiple primaries are also
// rejected: the engine consumes one.)
func (s *ScenarioSpec) PrimaryProceeding() (*ScenarioProceeding, error) {
var primary *ScenarioProceeding
for i := range s.Proceedings {
if s.Proceedings[i].Role == ScenarioRolePrimary {
if primary != nil {
return nil, fmt.Errorf("%w: multiple proceedings with role='primary'", ErrInvalidScenario)
}
primary = &s.Proceedings[i]
}
}
if primary == nil {
return nil, ErrScenarioNoPrimary
}
return primary, nil
}
// CalcOptionsFromSpec builds a CalcOptions from the scenario's primary
// entry. The caller still needs the proceeding code + the trigger date,
// both returned alongside.
//
// v1: only the primary entry is honoured. v2 will iterate over peer
// entries; the multi-peer merge lives in the paliad-side
// ProjectionService (one Calculate call per entry, merged + sorted by
// date).
func (s *ScenarioSpec) CalcOptionsFromSpec() (proceedingCode, triggerDate string, opts CalcOptions, err error) {
primary, err := s.PrimaryProceeding()
if err != nil {
return "", "", CalcOptions{}, err
}
td := s.BaseTriggerDate
if primary.TriggerDateOverride != "" {
td = primary.TriggerDateOverride
}
if td == "" {
return "", "", CalcOptions{}, fmt.Errorf("%w: no base_trigger_date and no per-proceeding override", ErrInvalidScenario)
}
perCardAppellant := make(map[string]string, len(primary.PerCardChoices))
skipRules := make(map[string]struct{}, len(primary.SkipRules))
includeCCRFor := make(map[string]struct{}, len(primary.PerCardChoices))
for code, choice := range primary.PerCardChoices {
if choice.Appellant != "" {
perCardAppellant[code] = choice.Appellant
}
if choice.IncludeCCR != nil && *choice.IncludeCCR {
includeCCRFor[code] = struct{}{}
}
if choice.Skip != nil && *choice.Skip {
skipRules[code] = struct{}{}
}
}
for _, code := range primary.SkipRules {
skipRules[code] = struct{}{}
}
return primary.Code, td, CalcOptions{
Flags: primary.Flags,
AnchorOverrides: primary.AnchorOverrides,
AppealTarget: primary.AppealTarget,
PerCardAppellant: perCardAppellant,
SkipRules: skipRules,
IncludeCCRFor: includeCCRFor,
}, nil
}
// ScenarioFilter narrows Catalog.LoadScenarios. All fields optional:
//
// - ProjectID non-nil: only scenarios attached to that project
// (project_id = filter.ProjectID).
// - AbstractForUser non-nil: only abstract scenarios (project_id IS
// NULL) created by that user.
// - Both nil: list every scenario the caller can see (RLS-gated).
type ScenarioFilter struct {
ProjectID *uuid.UUID
AbstractForUser *uuid.UUID
}
// CalculateFromScenario is the high-level engine entry for scenario-
// driven rendering. Unpacks the spec, builds CalcOptions, and delegates
// to Calculate.
//
// v1: surfaces only the primary proceeding's timeline. v2 multi-peer
// expansion lives on the paliad-side ProjectionService (per-entry
// Calculate + client-side merge); the package doesn't own that
// orchestration.
func CalculateFromScenario(
ctx context.Context,
scenario *Scenario,
catalog Catalog,
holidays HolidayCalendar,
courts CourtRegistry,
) (*Timeline, error) {
spec, err := ParseSpec(scenario.Spec)
if err != nil {
return nil, err
}
code, triggerDate, opts, err := spec.CalcOptionsFromSpec()
if err != nil {
return nil, err
}
return Calculate(ctx, code, triggerDate, opts, catalog, holidays, courts)
}

View File

@@ -0,0 +1,207 @@
package litigationplanner
import (
"strings"
"testing"
)
// TestParseSpec_Roundtrip pins the spec-decoder contract: well-formed
// jsonb with version=1 parses; unknown versions and malformed JSON
// surface ErrInvalidScenario.
func TestParseSpec_Roundtrip(t *testing.T) {
cases := []struct {
name string
spec string
wantErr bool
}{
{
"v1 primary-only",
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[{"code":"upc.inf.cfi","role":"primary"}]}`,
false,
},
{
"v1 with full primary entry",
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[
{"code":"upc.inf.cfi","role":"primary","flags":["with_ccr"],
"anchor_overrides":{"inf.reply":"2026-08-15"},
"skip_rules":["inf.r30_amend"]}
]}`,
false,
},
{
"v2 spec rejected — unknown version",
`{"version":2,"proceedings":[]}`,
true,
},
{
"empty spec",
``,
true,
},
{
"malformed json",
`{"version":1,"proceedings":[}`,
true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ParseSpec(NullableJSON(c.spec))
if c.wantErr && err == nil {
t.Errorf("ParseSpec(%s): want error, got nil", c.spec)
}
if !c.wantErr && err != nil {
t.Errorf("ParseSpec(%s): unexpected error %v", c.spec, err)
}
})
}
}
// TestScenarioSpec_PrimaryProceeding pins the "exactly one primary"
// invariant: zero → ErrScenarioNoPrimary; multiple → ErrInvalidScenario.
func TestScenarioSpec_PrimaryProceeding(t *testing.T) {
t.Run("zero primary → ErrScenarioNoPrimary", func(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
},
}
_, err := s.PrimaryProceeding()
if err != ErrScenarioNoPrimary {
t.Errorf("want ErrScenarioNoPrimary, got %v", err)
}
})
t.Run("two primaries rejected", func(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{
{Code: "upc.inf.cfi", Role: ScenarioRolePrimary},
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary},
},
}
_, err := s.PrimaryProceeding()
if err == nil || !strings.Contains(err.Error(), "multiple proceedings with role='primary'") {
t.Errorf("want multi-primary error, got %v", err)
}
})
t.Run("single primary picked", func(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary, Flags: []string{"with_amend"}},
},
}
p, err := s.PrimaryProceeding()
if err != nil {
t.Fatalf("PrimaryProceeding: %v", err)
}
if p.Code != "upc.rev.cfi" {
t.Errorf("primary code = %q, want upc.rev.cfi", p.Code)
}
if len(p.Flags) != 1 || p.Flags[0] != "with_amend" {
t.Errorf("primary.Flags = %v, want [with_amend]", p.Flags)
}
})
}
// TestScenarioSpec_CalcOptionsFromSpec covers the unpack from spec
// jsonb into the CalcOptions the engine consumes. Pins:
// - base_trigger_date used when no per-proceeding override
// - trigger_date_override wins when set
// - flags + anchor_overrides + appeal_target passed through verbatim
// - per_card_choices unpacked into PerCardAppellant / SkipRules /
// IncludeCCRFor maps
func TestScenarioSpec_CalcOptionsFromSpec(t *testing.T) {
includeTrue := true
skipTrue := true
s := &ScenarioSpec{
Version: 1,
BaseTriggerDate: "2026-05-26",
Proceedings: []ScenarioProceeding{{
Code: "upc.inf.cfi",
Role: ScenarioRolePrimary,
Flags: []string{"with_ccr"},
AnchorOverrides: map[string]string{"inf.reply": "2026-08-15"},
AppealTarget: "endentscheidung",
SkipRules: []string{"explicit_skip_code"},
PerCardChoices: map[string]ScenarioCardChoice{
"inf.r30_amend": {Appellant: "claimant"},
"inf.rejoin": {IncludeCCR: &includeTrue},
"inf.amend_other": {Skip: &skipTrue},
},
}},
}
code, td, opts, err := s.CalcOptionsFromSpec()
if err != nil {
t.Fatalf("CalcOptionsFromSpec: %v", err)
}
if code != "upc.inf.cfi" {
t.Errorf("code = %q, want upc.inf.cfi", code)
}
if td != "2026-05-26" {
t.Errorf("triggerDate = %q, want 2026-05-26", td)
}
if len(opts.Flags) != 1 || opts.Flags[0] != "with_ccr" {
t.Errorf("opts.Flags = %v, want [with_ccr]", opts.Flags)
}
if opts.AppealTarget != "endentscheidung" {
t.Errorf("opts.AppealTarget = %q, want endentscheidung", opts.AppealTarget)
}
if got := opts.AnchorOverrides["inf.reply"]; got != "2026-08-15" {
t.Errorf("opts.AnchorOverrides[inf.reply] = %q, want 2026-08-15", got)
}
if got := opts.PerCardAppellant["inf.r30_amend"]; got != "claimant" {
t.Errorf("opts.PerCardAppellant[inf.r30_amend] = %q, want claimant", got)
}
if _, ok := opts.IncludeCCRFor["inf.rejoin"]; !ok {
t.Error("opts.IncludeCCRFor missing inf.rejoin")
}
if _, ok := opts.SkipRules["inf.amend_other"]; !ok {
t.Error("opts.SkipRules missing inf.amend_other (from per_card_choices.skip)")
}
if _, ok := opts.SkipRules["explicit_skip_code"]; !ok {
t.Error("opts.SkipRules missing explicit_skip_code (from skip_rules[])")
}
}
// TestScenarioSpec_TriggerDateOverride pins the per-proceeding override
// path (v2-ready — primary entry honours trigger_date_override too).
func TestScenarioSpec_TriggerDateOverride(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
BaseTriggerDate: "2026-05-26",
Proceedings: []ScenarioProceeding{{
Code: "upc.inf.cfi",
Role: ScenarioRolePrimary,
TriggerDateOverride: "2026-12-01",
}},
}
_, td, _, err := s.CalcOptionsFromSpec()
if err != nil {
t.Fatalf("CalcOptionsFromSpec: %v", err)
}
if td != "2026-12-01" {
t.Errorf("triggerDate = %q, want override 2026-12-01", td)
}
}
// TestScenarioSpec_NoBaseTrigger pins the safety check that a spec
// without base_trigger_date AND without per-proceeding override
// surfaces ErrInvalidScenario (the engine can't render without a date).
func TestScenarioSpec_NoBaseTrigger(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{{
Code: "upc.inf.cfi",
Role: ScenarioRolePrimary,
}},
}
_, _, _, err := s.CalcOptionsFromSpec()
if err == nil {
t.Fatal("want ErrInvalidScenario, got nil")
}
}

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
@@ -371,6 +430,33 @@ type TimelineEntry struct {
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
AppellantContext string `json:"appellantContext,omitempty"`
IsHidden bool `json:"isHidden,omitempty"`
// DurationValue / DurationUnit / Timing surface the rule's
// arithmetic so /tools/verfahrensablauf can show "2 Mo. nach" on
// each event card (m/paliad#133, t-paliad-302). Source values from
// the Rule row (not the post-alt-swap arithmetic) — the tooltip
// reads as a property of the rule, not a recap of which branch
// fired. Zero-duration rules (root event, court-set) emit
// DurationValue=0 and the frontend suppresses the affordance.
// Timing is "before" | "after" — empty when r.Timing is NULL.
DurationValue int `json:"durationValue,omitempty"`
DurationUnit string `json:"durationUnit,omitempty"`
Timing string `json:"timing,omitempty"`
// AppealRole carries the rule's appeal-filer role (t-paliad-307 /
// m/paliad#136 Bug 1) when the timeline was computed under an
// appeal_target filter. One of AppealRoleAppellant /
// AppealRoleAppellee, or empty for court events / non-appeal
// timelines. The frontend column-bucketer reads this to route
// primary_party='both' rules to Berufungskläger vs
// Berufungsbeklagter columns once the user picks a side.
AppealRole string `json:"appealRole,omitempty"`
// IsTriggerEvent marks the synthetic root row that represents the
// decision being appealed (t-paliad-307 / m/paliad#136 Bug 2).
// Distinct from IsRootEvent in that the row carries no real rule
// id — it's a UI marker dated to the trigger date with the
// per-appeal-target label from TriggerEventLabelForAppealTarget.
IsTriggerEvent bool `json:"isTriggerEvent,omitempty"`
}
// RuleCalculation is the single-rule calc response that backs the
@@ -426,6 +512,76 @@ type FristenrechnerType struct {
Group string `json:"group"`
}
// EventLookupAxes carries the optional filter axes for
// Catalog.LookupEvents (Slice B2, m/paliad#124 §18.2). All fields are
// optional; the empty value (or nil pointer) is "no filter on this
// axis". When multiple axes are set the catalog applies them as AND —
// a rule must match every non-zero axis to be returned. An axis set
// to an unknown value (jurisdiction="XX", party="foo") is treated the
// same as "no filter on this axis" so a stale frontend doesn't
// silently drop the entire result set.
//
// AppealTarget narrows to rules whose AppliesToTarget contains the
// requested slug (same semantic as CalcOptions.AppealTarget). Useful
// for the unified UPC Berufung lookup.
type EventLookupAxes struct {
// Jurisdiction filters by paliad.proceeding_types.jurisdiction
// ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any.
Jurisdiction string
// ProceedingTypeID narrows to one proceeding. nil = any.
ProceedingTypeID *int
// Party filters by paliad.deadline_rules.primary_party
// ("claimant" | "defendant" | "court" | "both"). Empty = any.
// Validated against PrimaryParties before the SQL pass; unknown
// values fall through as "no filter".
Party string
// EventCategoryID narrows to rules associated with one
// event_categories row via the
// deadline_concept_event_types junction. nil = any.
EventCategoryID *uuid.UUID
// AppealTarget filters by Rule.AppliesToTarget containing the
// requested slug (e.g. "endentscheidung"). Empty = any.
// Validated against AppealTargets before the SQL pass.
AppealTarget string
}
// EventLookupDepth controls the sequence-depth of the returned events.
type EventLookupDepth string
const (
// EventLookupDepthNext returns immediate children of the matched
// anchor (1 hop downstream via parent_id). Useful for "what comes
// next from this point?" queries.
EventLookupDepthNext EventLookupDepth = "next"
// EventLookupDepthAllFollowing returns the entire downstream
// chain (parent_id walk to leaves). Useful for "show me the
// whole sequence from here onward" queries.
EventLookupDepthAllFollowing EventLookupDepth = "all-following"
)
// EventMatch is one result row from Catalog.LookupEvents.
type EventMatch struct {
// Rule carries the full deadline-rule row including parent_id,
// duration_value/_unit, condition_expr, applies_to_target, etc.
Rule Rule `json:"rule"`
// ProceedingType is the owning proceeding metadata. Lets the
// frontend render the "from <proceeding>" badge without a second
// roundtrip.
ProceedingType ProceedingType `json:"proceedingType"`
// Priority surfaces Rule.Priority at the top level for
// convenience — the four-value vocab (mandatory / recommended /
// optional / informational).
Priority string `json:"priority"`
// DepthFromAnchor is 1 for the immediate match, 2+ for deeper
// descendants returned under EventLookupDepthAllFollowing.
// Always >= 1 for any returned row.
DepthFromAnchor int `json:"depthFromAnchor"`
// ParentRuleID is the parent rule's UUID when that parent is
// itself in the returned result set (so the frontend can render
// a tree). nil when the parent is outside the returned set.
ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
}
// TriggerEvent is a UPC procedural event referenced by deadline rules
// whose semantic anchor is an event rather than a parent rule (the
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
@@ -481,6 +637,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