Compare commits

...

23 Commits

Author SHA1 Message Date
mAi
8125caf49a test(backup): add TEST_DATABASE_URL-gated live smokes for org export
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 complementary live tests (both skipped without TEST_DATABASE_URL):

- TestResolveOrgSheets_LiveSchemaSnapshot — runs the schema probe + SQL
  composer the way the backup runner does at the start of every run,
  then executes each resolved SELECT against the live DB (wrapped in
  LIMIT 1 to keep table reads cheap). A future column rename in a
  table our spec still names triggers this test and surfaces in CI
  before /admin/backups breaks.

- TestWriteOrg_LiveSmoke — end-to-end pipeline against a real DB:
  schema probe, REPEATABLE READ tx, every sheet query, xlsx + JSON +
  per-sheet CSV assembly, outer zip framing. Spot-checks meta.RowCounts
  and the zip magic bytes; doesn't materialise the full bundle to
  disk.

Both tests exercise the exact failure mode m/paliad#140 reproduced
(hardcoded ORDER BY against a renamed column) so CI catches regressions
once TEST_DATABASE_URL is wired.

m/paliad#140
2026-05-26 18:19:55 +02:00
mAi
935ea23038 refactor(backup): make orgSheetQueries drift-resistant
Refactor orgSheetQueries() into orgSheetSpecs() returning declarative
(SheetName, Table, OrderBy []string) triples instead of free-form SQL,
with composeOrgSheetSQL() as a pure builder and resolveOrgSheets() as
the DB-touching orchestrator.

At backup time the resolver:
  1. probes information_schema.columns once for every spec table,
  2. composes SELECT * FROM <table> ORDER BY <columns-that-exist>,
  3. logs WARN per ORDER BY column dropped because it's gone.

A future column rename or removal can no longer break /admin/backups:
the worst case is one sheet temporarily losing sort stability, and the
WARN log surfaces which spec needs updating.

Sheets needing custom projections (documents drops ai_extracted) keep
the SQL override path. All other org-scope sheets — entity + ref__ —
declare their ORDER BY as a column list.

Tests:
  - 6 composeOrgSheetSQL unit tests cover the drift behaviour with no
    DB needed (missing column, all-missing, override bypass, declared
    order preserved, unknown table)
  - Existing registry-shape tests (no duplicates, no paliadin leakage,
    ref__ prefix, ORDER BY-for-determinism) updated to the spec API
  - Full internal/services suite green

m/paliad#140
2026-05-26 18:17:21 +02:00
mAi
da464813b7 fix(backup): repair 4 broken ORDER BY columns in orgSheetQueries
Backup export was 100% broken because four sheets referenced columns
that no longer exist (or never did) in their target tables:

- email_templates: ORDER BY id → key, lang (composite PK)
- policy_audit_log: ORDER BY changed_at → created_at
- ref__deadline_event_types: ORDER BY rule_id → deadline_id (post-rename)
- ref__event_category_concepts: ORDER BY category_id → event_category_id

Audited every entry in orgSheetQueries() against information_schema.columns;
these were the only mismatches. Patch unblocks /admin/backups → Generate.
Drift-resistant refactor (m/paliad#140 Part B) follows in a separate commit.

m/paliad#140
2026-05-26 18:13:27 +02:00
mAi
6d24fb8931 Merge: t-paliad-310 — dark-mode CSS: repoint 12 var(--color-surface-alt) sites to defined tokens (m/paliad#138)
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:07:45 +02:00
mAi
446c46e5c5 fix(css): repoint 12 var(--color-surface-alt, hex) sites to defined tokens (t-paliad-310, m/paliad#138)
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 --color-surface-alt token was never defined in :root or :root[data-theme="dark"],
so the var() fallback hex literal always won — leaving 12 surface sites with
zero dark-mode treatment. Same pattern as t-paliad-087 / t-paliad-150 / t-paliad-291.

Issue #138 surfaced four panels visibly broken in dark mode:
1. submission-draft no-project banner ("Kein Projekt zugeordnet…") — white-on-white
2. submission-draft preview header ("Vorschau / Read-only Vorschau…") — white-on-white
3. smart-timeline rule-chip (e.g. de.null.bpatg.berufung in Vorhersage rows) — grey-on-grey
4. submission-draft addparty manual form (Manuell / Aus DB / Name / …) — white-on-white

Eight more latent sites with the same root cause are fixed in the same pass:
.submissions-new-chip:hover, .submissions-new-project-item:hover,
.submission-draft-import-row, .submission-draft-addparty-search-projref,
.collab-invite-hint, .smart-timeline-status-icon,
.smart-timeline-kind-chip--projected, .smart-timeline-add-choice:hover.

Each site repointed to the semantically correct existing token
(--color-surface-2 for #fafafa, --color-surface-muted for #f4f4f4,
--color-bg-subtle for #f7f7f0, --color-bg-lime-tint for the lime-tinted
collab-invite-hint). All four target tokens are defined in both :root
and :root[data-theme="dark"]. No new tokens introduced.

Light-mode hex values are functionally identical (#fafafa==#fafafa,
#f4f4f4≈#f3f4f6, #f7f7f0≈#f7f3f0).

Verified: bun run build clean; Playwright screenshots of the four panels
in both light + dark modes show correct rendering.
2026-05-26 18:07:02 +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 / 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: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 / 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: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
42 changed files with 4464 additions and 136 deletions

View File

@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"syscall"
"time"
// Embed Go's IANA tz database into the binary so time.LoadLocation works
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
@@ -221,6 +222,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 +340,13 @@ func main() {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
// Slice B.2 dual-write drift check (t-paliad-305 / m/paliad#93).
// Runs every 6 h while the new procedural_events / sequencing_rules /
// legal_sources tables shadow the legacy paliad.deadline_rules
// table. A clean run logs at INFO; drift logs at WARN with the
// full report so a broken dual-write surfaces before the next
// deploy.
services.StartDualWriteDriftCheckLoop(bgCtx, pool, 6*time.Hour)
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")

View File

@@ -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",
@@ -3406,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",

View File

@@ -61,8 +61,14 @@ let sidePrefilledFromProject = false;
// chosen side's column). For first-instance proceedings (Inf, Rev,
// …) the side picker still narrows columns but doesn't collapse
// the "both" rows.
//
// 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.unified",
"de.inf.olg",
"de.inf.bgh",
"de.null.bgh",
@@ -276,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 /
@@ -482,6 +503,7 @@ function renderResults(data: DeadlineResponse) {
? renderColumnsBody(data, {
editable: true,
showNotes,
showDurations,
side: currentSide,
// t-paliad-301: the appellant axis collapses into the single
// side picker. For role-swap proceedings, currentSide IS the
@@ -489,8 +511,14 @@ function renderResults(data: DeadlineResponse) {
// 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";
@@ -868,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

@@ -1264,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"

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);
@@ -6220,7 +6230,7 @@ dialog.modal::backdrop {
align-items: baseline;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-alt, #fafafa);
background: var(--color-surface-2);
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -6378,7 +6388,7 @@ dialog.modal::backdrop {
}
.submissions-new-chip:hover {
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
}
.submissions-new-chip--active {
@@ -6416,7 +6426,7 @@ dialog.modal::backdrop {
}
.submissions-new-project-item:hover {
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
}
.submissions-new-project-title {
@@ -6431,7 +6441,7 @@ dialog.modal::backdrop {
flex-wrap: wrap;
padding: 0.75rem 1rem;
margin: 0 0 1.25rem;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-accent, #c6f41c);
border-radius: 6px;
@@ -6454,7 +6464,7 @@ dialog.modal::backdrop {
flex-wrap: wrap;
padding: 0.5rem 0.6rem;
margin-bottom: 0.75rem;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: 6px;
}
@@ -6582,7 +6592,7 @@ dialog.modal::backdrop {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.6rem;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -6705,7 +6715,7 @@ dialog.modal::backdrop {
margin-left: 0.3rem;
padding: 0 0.4em;
border-radius: 3px;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
color: var(--color-text-muted);
}
@@ -7912,7 +7922,7 @@ dialog.modal::backdrop {
.collab-invite-hint {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface-alt, var(--color-bg-lime-tint));
background: var(--color-bg-lime-tint);
border: 1px dashed var(--color-border);
border-radius: var(--radius);
font-size: 0.85rem;
@@ -16572,7 +16582,7 @@ dialog.quick-add-sheet::backdrop {
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: 600;
@@ -16626,7 +16636,7 @@ dialog.quick-add-sheet::backdrop {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-weight: 500;
letter-spacing: 0.02em;
@@ -16648,7 +16658,7 @@ dialog.quick-add-sheet::backdrop {
}
.smart-timeline-kind-chip--projected {
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-style: italic;
}
@@ -16715,7 +16725,7 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-add-choice:hover:not(:disabled) {
border-color: var(--color-accent-fg);
background: var(--color-surface-alt, #fafafa);
background: var(--color-surface-2);
}
.smart-timeline-add-choice--primary {

View File

@@ -341,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,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,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

@@ -0,0 +1,99 @@
package services
import (
"bytes"
"context"
"os"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
// TestResolveOrgSheets_LiveSchemaSnapshot probes the live paliad schema
// the way the backup runner does at the start of every run, then asserts
// that every spec the registry declares either keeps all its ORDER BY
// columns or — if any are missing — composes a fallback SELECT that the
// DB can still execute. Catches the m/paliad#140 class of bug
// (hardcoded ORDER BY against a renamed column) before deploy.
//
// Skipped when TEST_DATABASE_URL is unset. Read-only: opens a
// REPEATABLE READ tx, never writes.
func TestResolveOrgSheets_LiveSchemaSnapshot(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
specs := orgSheetSpecs()
sheets, err := resolveOrgSheets(ctx, pool, specs)
if err != nil {
t.Fatalf("resolveOrgSheets: %v", err)
}
if len(sheets) != len(specs) {
t.Fatalf("resolved %d sheets, want %d", len(sheets), len(specs))
}
// Each resolved SELECT must run cleanly against the live schema.
// We LIMIT 1 inside a sub-SELECT so we don't materialise the full
// table (some are large) but still exercise the ORDER BY clause.
for _, sq := range sheets {
wrapped := `SELECT * FROM (` + sq.SQL + `) _wrap LIMIT 1`
if _, err := pool.QueryxContext(ctx, wrapped, sq.Args...); err != nil {
t.Errorf("sheet %q SQL failed: %v\nSQL: %s", sq.SheetName, err, sq.SQL)
}
}
}
// TestWriteOrg_LiveSmoke runs the full ExportService.WriteOrg pipeline
// against a real DB: schema probe, REPEATABLE READ tx, every sheet
// query, xlsx + json + per-sheet CSV assembly, outer zip framing.
// Discards the bytes — this is a "does it crash" smoke, the bug class
// it catches is exactly the one from m/paliad#140 (hardcoded ORDER BY
// against a missing column).
//
// Skipped when TEST_DATABASE_URL is unset.
func TestWriteOrg_LiveSmoke(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
svc := NewExportService(pool, "test-firm")
var buf bytes.Buffer
meta, err := svc.WriteOrg(context.Background(), &buf, ExportSpec{
ActorID: uuid.New(),
ActorEmail: "backup-smoke@test.local",
ActorLabel: "Backup Smoke",
})
if err != nil {
t.Fatalf("WriteOrg: %v", err)
}
if buf.Len() == 0 {
t.Fatalf("WriteOrg wrote no bytes")
}
// Spot-check meta fills.
if meta.Scope != ExportScopeOrg {
t.Errorf("meta.Scope = %q, want %q", meta.Scope, ExportScopeOrg)
}
if len(meta.RowCounts) != len(orgSheetSpecs()) {
t.Errorf("meta.RowCounts has %d entries, want %d (one per sheet)", len(meta.RowCounts), len(orgSheetSpecs()))
}
// The bytes are a zip; the first 4 bytes are PK\x03\x04 for a non-empty zip.
if buf.Len() >= 4 && !strings.HasPrefix(buf.String()[:4], "PK\x03\x04") {
t.Errorf("bundle bytes don't look like a zip (first bytes: %x)", buf.Bytes()[:4])
}
}

View File

@@ -6,8 +6,10 @@ package services
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
// This file covers the bits that don't need a database:
//
// - orgSheetQueries registry shape: no duplicates, no excluded
// - orgSheetSpecs registry shape: no duplicates, no excluded
// paliadin sheets, predictable prefix split between entity and ref.
// - composeOrgSheetSQL drift-resistance: missing ORDER BY cols drop,
// SQL override path bypasses the builder, all-missing → no clause.
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
// URI traversal rejection.
@@ -22,60 +24,216 @@ import (
)
// ---------------------------------------------------------------------------
// orgSheetQueries registry
// orgSheetSpecs registry
// ---------------------------------------------------------------------------
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
func TestOrgSheetSpecs_NoDuplicates(t *testing.T) {
seen := map[string]bool{}
for _, sq := range orgSheetQueries() {
if seen[sq.SheetName] {
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
for _, sp := range orgSheetSpecs() {
if seen[sp.SheetName] {
t.Fatalf("duplicate sheet name in orgSheetSpecs: %q", sp.SheetName)
}
seen[sq.SheetName] = true
seen[sp.SheetName] = true
}
}
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
func TestOrgSheetSpecs_ExcludesPaliadinTables(t *testing.T) {
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
// from the registry (structural exclusion, not just column-drop).
for _, sq := range orgSheetQueries() {
name := sq.SheetName
for _, sp := range orgSheetSpecs() {
name := sp.SheetName
if strings.Contains(name, "paliadin") {
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
t.Fatalf("orgSheetSpecs leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
}
// Belt-and-braces: SQL bodies should not reference the tables
// either (no UNION joins, no subqueries pulling them in).
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
if strings.Contains(sp.Table, "paliadin") {
t.Fatalf("orgSheetSpecs[%q].Table references a paliadin table: %s", name, sp.Table)
}
// Belt-and-braces: SQL override bodies (the few sheets that
// bypass the Table+OrderBy builder) also can't pull paliadin
// tables in through UNION/subquery.
if strings.Contains(sp.SQL, "paliadin_turns") || strings.Contains(sp.SQL, "paliadin_aichat_conversation") {
t.Fatalf("orgSheetSpecs[%q] SQL references a paliadin table: %s", name, sp.SQL)
}
}
}
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
func TestOrgSheetSpecs_RefSheetsPrefixed(t *testing.T) {
// Every sheet whose data is read-only reference material is
// expected to use the `ref__` prefix. The writer's downstream
// consumers rely on this convention to group reference data
// visually in the workbook.
for _, sq := range orgSheetQueries() {
if !strings.HasPrefix(sq.SheetName, "ref__") {
for _, sp := range orgSheetSpecs() {
if !strings.HasPrefix(sp.SheetName, "ref__") {
continue
}
// Reference sheets shouldn't carry per-row WHERE clauses (they
// dump the whole reference table for portability).
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
// dump the whole reference table for portability). Only
// applies to the SQL-override path; the Table+OrderBy builder
// never emits a WHERE.
if sp.SQL != "" && strings.Contains(strings.ToUpper(sp.SQL), "WHERE") {
t.Fatalf("orgSheetSpecs[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sp.SheetName)
}
}
}
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
// Every sheet must specify an ORDER BY so the byte-deterministic
// contract from t-paliad-214 §3 holds across runs.
for _, sq := range orgSheetQueries() {
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
func TestOrgSheetSpecs_OrderByForDeterminism(t *testing.T) {
// Every sheet must declare a stable sort: either OrderBy on the
// Table+OrderBy path, or ORDER BY in the SQL override. Keeps the
// byte-deterministic contract from t-paliad-214 §3 across runs.
//
// (Drift removes ORDER BY columns at runtime, but only ones that
// no longer exist in the schema — the spec-level declaration is
// still required so we know what *should* be ordered.)
for _, sp := range orgSheetSpecs() {
if sp.SQL != "" {
if !strings.Contains(strings.ToUpper(sp.SQL), "ORDER BY") {
t.Fatalf("orgSheetSpecs[%q] SQL override missing ORDER BY (determinism contract): %s", sp.SheetName, sp.SQL)
}
continue
}
if len(sp.OrderBy) == 0 {
t.Fatalf("orgSheetSpecs[%q] has no OrderBy and no SQL override (determinism contract)", sp.SheetName)
}
}
}
// ---------------------------------------------------------------------------
// composeOrgSheetSQL — drift-resistant SQL builder
// ---------------------------------------------------------------------------
func TestComposeOrgSheetSQL_AllColumnsPresent(t *testing.T) {
spec := orgSheetSpec{
SheetName: "appointments",
Table: "paliad.appointments",
OrderBy: []string{"id"},
}
cols := map[string]map[string]struct{}{
"appointments": {"id": {}, "project_id": {}},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.appointments ORDER BY id"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 0 {
t.Fatalf("expected no dropped columns, got %v", dropped)
}
}
func TestComposeOrgSheetSQL_DropsMissingOrderByColumn(t *testing.T) {
// The original bug from m/paliad#138 reproduced in unit form:
// orderBy references a column the table doesn't have.
spec := orgSheetSpec{
SheetName: "appointment_caldav_targets",
Table: "paliad.appointment_caldav_targets",
OrderBy: []string{"appointment_id", "calendar_binding_id"}, // wrong: real col is binding_id
}
cols := map[string]map[string]struct{}{
"appointment_caldav_targets": {
"appointment_id": {},
"binding_id": {},
},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 1 || dropped[0] != "calendar_binding_id" {
t.Fatalf("expected dropped=[calendar_binding_id], got %v", dropped)
}
}
func TestComposeOrgSheetSQL_AllOrderByMissing_NoClause(t *testing.T) {
// If every declared ORDER BY column is gone, the builder still
// produces a runnable SELECT — without ORDER BY. The export
// succeeds; the order across runs is no longer deterministic for
// this sheet until the spec is updated. WARN log alerts the
// operator (verified in TestResolveOrgSheets_LogsWarnings).
spec := orgSheetSpec{
SheetName: "ghost",
Table: "paliad.ghost",
OrderBy: []string{"missing_a", "missing_b"},
}
cols := map[string]map[string]struct{}{
"ghost": {"unrelated": {}},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.ghost"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 2 {
t.Fatalf("expected 2 dropped columns, got %v", dropped)
}
}
func TestComposeOrgSheetSQL_SQLOverride_BypassesBuilder(t *testing.T) {
// When a sheet declares SQL, the builder MUST NOT touch it — even
// if the column knowledge would suggest a change. Custom
// projections (documents drops ai_extracted) and special-case
// joins both rely on this.
spec := orgSheetSpec{
SheetName: "documents",
Table: "paliad.documents", // should be ignored
OrderBy: []string{"id"}, // should be ignored
SQL: "SELECT id, title FROM paliad.documents ORDER BY id",
}
cols := map[string]map[string]struct{}{
"documents": {}, // empty → would drop everything if builder ran
}
got, dropped := composeOrgSheetSQL(spec, cols)
if got != spec.SQL {
t.Fatalf("SQL override mutated: got %q, want %q", got, spec.SQL)
}
if len(dropped) != 0 {
t.Fatalf("override path should never report drops; got %v", dropped)
}
}
func TestComposeOrgSheetSQL_UnknownTable_DropsAllOrderBy(t *testing.T) {
// A table missing entirely from the schema snapshot is treated as
// "no columns known" — every ORDER BY column gets dropped, but
// the SELECT still emits (so a stale registry doesn't crash the
// backup; the operator gets WARNs to fix it).
spec := orgSheetSpec{
SheetName: "renamed_table",
Table: "paliad.renamed_table",
OrderBy: []string{"id"},
}
got, dropped := composeOrgSheetSQL(spec, map[string]map[string]struct{}{})
want := "SELECT * FROM paliad.renamed_table"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 1 || dropped[0] != "id" {
t.Fatalf("expected dropped=[id], got %v", dropped)
}
}
func TestComposeOrgSheetSQL_PreservesOrderByOrder(t *testing.T) {
// Multi-column OrderBy must keep its declared order, with kept
// columns concatenated in the same sequence. Determinism contract
// from t-paliad-214 §3 depends on this.
spec := orgSheetSpec{
SheetName: "partner_unit_members",
Table: "paliad.partner_unit_members",
OrderBy: []string{"partner_unit_id", "missing_middle", "user_id"},
}
cols := map[string]map[string]struct{}{
"partner_unit_members": {
"partner_unit_id": {},
"user_id": {},
},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 1 || dropped[0] != "missing_middle" {
t.Fatalf("expected dropped=[missing_middle], got %v", dropped)
}
}

View File

@@ -55,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`)
}
@@ -100,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
@@ -152,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)
@@ -175,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
)
@@ -196,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 {
@@ -264,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 {
@@ -292,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)
@@ -327,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

@@ -272,7 +272,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.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`
@@ -585,6 +585,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.2 dual-write (t-paliad-305): if rule_id was in the
// patch (auto/custom swap from t-paliad-258), the parallel
// procedural_event_id + sequencing_rule_id columns must follow.
// Call unconditionally — it's a single UPDATE keyed on
// deadlineID and a no-op when rule_id is unchanged.
if input.RuleSet {
if err := syncDeadlineDualLinks(ctx, tx, deadlineID); err != nil {
return nil, err
}
}
}
if input.EventTypeIDs != nil && s.eventTypes != nil {

View File

@@ -0,0 +1,392 @@
// Slice B.2 dual-write (t-paliad-305 / m/paliad#93) — keep paliad's
// new tables (procedural_events / sequencing_rules / legal_sources) in
// lock-step with the legacy paliad.deadline_rules table during the
// dual-write window. Mig 136 (Slice B.1) created the new tables and
// backfilled them once. This file keeps them in sync going forward.
//
// Contract:
//
// - Every RuleEditorService method that mutates paliad.deadline_rules
// calls syncDualWriteFromDeadlineRule(ctx, tx, id) inside the same
// transaction, AFTER the deadline_rules write, BEFORE tx.Commit.
// - The sync is idempotent (INSERT … ON CONFLICT … DO UPDATE) so the
// same call works for Create (new row), UpdateDraft (existing row),
// CloneAsDraft (new row referencing an old row), Publish (lifecycle
// flip), Archive/Restore (lifecycle flip), and the published-peer
// archive that Publish performs as a cascade.
// - The sync re-derives the new-table state from paliad.deadline_rules
// in pure SQL — no struct mapping in Go. The legacy table stays the
// source of truth during B.2 (B.3 flips reads, B.4 drops it).
// - Read paths still read deadline_rules in B.2. The new tables are a
// parallel projection kept consistent for B.3's read cutover; they
// are not yet authoritative.
//
// Why a per-row sync instead of a global trigger:
//
// - The deadline_rules audit trigger (mig 079) reads paliad.audit_reason
// to record the rationale on every change. Putting the new-table
// write in the same TX preserves that auditability — set_config is
// transactional and the new writes share the same reason.
// - A Postgres-side AFTER UPDATE trigger on deadline_rules would also
// work but it's harder to test in isolation and harder to revert
// when B.4 drops the source table. A Go-side sync is reversible
// with a code revert; an SQL trigger needs a follow-up migration.
//
// The drift-check job (CheckDualWriteDrift below) runs daily and
// alerts on mismatches. If the sync ever silently misses a row, the
// drift check surfaces it inside one day.
//
// See docs/design-procedural-events-model-2026-05-25.md §5.2 (dual-write
// phase) and docs/design-procedural-events-b0-findings-2026-05-26.md §7.
package services
import (
"context"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// syncDualWriteFromDeadlineRule re-projects the deadline_rules row with
// the given id into legal_sources + procedural_events + sequencing_rules.
// Runs three UPSERT statements in the open transaction.
//
// Synthetic-code rule (for rows where deadline_rules.submission_code is
// NULL) mirrors mig 136's backfill: 'null.' || first 8 hex chars of the
// uuid (dashes stripped). This must stay byte-identical to the mig 136
// expression or the lookup join inside the sequencing_rules UPSERT
// misses.
func syncDualWriteFromDeadlineRule(ctx context.Context, tx *sqlx.Tx, id uuid.UUID) error {
// 1. legal_sources — UPSERT the citation (no-op if already present).
// jurisdiction is parsed from the first dot-separated segment;
// 'other' on empty (paranoid fallback, no live rows hit it).
if _, err := tx.ExecContext(ctx, `
INSERT INTO paliad.legal_sources (citation, jurisdiction)
SELECT dr.legal_source,
COALESCE(NULLIF(split_part(dr.legal_source, '.', 1), ''), 'other')
FROM paliad.deadline_rules dr
WHERE dr.id = $1 AND dr.legal_source IS NOT NULL
ON CONFLICT (citation) DO NOTHING`, id); err != nil {
return fmt.Errorf("dual-write legal_sources for rule %s: %w", id, err)
}
// 2. procedural_events — UPSERT keyed by code. The code is the
// submission_code if present, else the synthetic 'null.<8hex>'
// minted from the deadline_rules row's id (matches mig 136).
// legal_source_id is resolved by JOIN on legal_sources.citation
// (NULL when the rule has no legal_source).
if _, err := tx.ExecContext(ctx, `
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
COALESCE(dr.submission_code,
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)),
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.id = $1
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 = EXCLUDED.lifecycle_state,
published_at = EXCLUDED.published_at,
is_active = EXCLUDED.is_active,
updated_at = now()`, id); err != nil {
return fmt.Errorf("dual-write procedural_events for rule %s: %w", id, err)
}
// 3. sequencing_rules — UPSERT keyed by id (1:1 inheritance from
// deadline_rules.id). procedural_event_id resolved by JOIN on
// the (real or synthetic) code. All hat-3 mechanics columns copy
// 1:1 from the deadline_rules row's post-write state.
if _, err := tx.ExecContext(ctx, `
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, 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))
WHERE dr.id = $1
ON CONFLICT (id) DO UPDATE SET
procedural_event_id = EXCLUDED.procedural_event_id,
proceeding_type_id = EXCLUDED.proceeding_type_id,
parent_id = EXCLUDED.parent_id,
trigger_event_id = EXCLUDED.trigger_event_id,
duration_value = EXCLUDED.duration_value,
duration_unit = EXCLUDED.duration_unit,
timing = EXCLUDED.timing,
alt_duration_value = EXCLUDED.alt_duration_value,
alt_duration_unit = EXCLUDED.alt_duration_unit,
alt_rule_code = EXCLUDED.alt_rule_code,
anchor_alt = EXCLUDED.anchor_alt,
combine_op = EXCLUDED.combine_op,
condition_expr = EXCLUDED.condition_expr,
primary_party = EXCLUDED.primary_party,
sequence_order = EXCLUDED.sequence_order,
is_spawn = EXCLUDED.is_spawn,
spawn_label = EXCLUDED.spawn_label,
spawn_proceeding_type_id = EXCLUDED.spawn_proceeding_type_id,
is_bilateral = EXCLUDED.is_bilateral,
is_court_set = EXCLUDED.is_court_set,
priority = EXCLUDED.priority,
rule_code = EXCLUDED.rule_code,
rule_codes = EXCLUDED.rule_codes,
deadline_notes = EXCLUDED.deadline_notes,
deadline_notes_en = EXCLUDED.deadline_notes_en,
choices_offered = EXCLUDED.choices_offered,
applies_to_target = EXCLUDED.applies_to_target,
lifecycle_state = EXCLUDED.lifecycle_state,
draft_of = EXCLUDED.draft_of,
published_at = EXCLUDED.published_at,
is_active = EXCLUDED.is_active,
updated_at = now()`, id); err != nil {
return fmt.Errorf("dual-write sequencing_rules for rule %s: %w", id, err)
}
return nil
}
// syncDeadlineDualLinks mirrors a deadline's legacy rule_id back-link
// onto the new procedural_event_id + sequencing_rule_id columns added
// by mig 136. Call this within an open transaction AFTER any UPDATE
// that mutates paliad.deadlines.rule_id (mig 122 introduced rule_id
// as the deadline→rule FK; today's writers are DeadlineService.Update
// and RuleEditorService.ResolveOrphan).
//
// Idempotent: NULL rule_id collapses both new columns to NULL by virtue
// of the subquery returning NULL. Slice B.2 (t-paliad-305).
func syncDeadlineDualLinks(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error {
if _, err := tx.ExecContext(ctx, `
UPDATE paliad.deadlines d
SET sequencing_rule_id = d.rule_id,
procedural_event_id = (
SELECT sr.procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.id = d.rule_id
)
WHERE d.id = $1`, deadlineID); err != nil {
return fmt.Errorf("sync deadline dual-links for %s: %w", deadlineID, err)
}
return nil
}
// DualWriteDriftReport summarises the comparison between the legacy
// paliad.deadline_rules table and the new procedural_events /
// sequencing_rules tables that B.2's dual-write is meant to keep in
// sync. A zero-drift report (every count delta zero, every join clean)
// is the steady state during the dual-write window; any non-zero field
// is the signal that a write path either bypassed
// syncDualWriteFromDeadlineRule or that an out-of-band mutation
// happened (e.g. raw SQL run by an operator).
type DualWriteDriftReport struct {
// Counts on the legacy and the projected side.
DeadlineRules int `json:"deadline_rules"`
SequencingRules int `json:"sequencing_rules"`
ProceduralEvents int `json:"procedural_events"`
LegalSources int `json:"legal_sources"`
// Expected (from the legacy side) vs observed (on the new side).
ExpectedPE int `json:"expected_procedural_events"`
ExpectedLegalSources int `json:"expected_legal_sources"`
// MissingSR — deadline_rules rows with no sequencing_rules row by id.
// OrphanedSR — sequencing_rules rows whose id doesn't exist in
// deadline_rules anymore (would only happen with a deletion path
// that bypasses dual-write).
MissingSR int `json:"missing_sequencing_rules"`
OrphanedSR int `json:"orphaned_sequencing_rules"`
// MismatchedLifecycle — rows where deadline_rules.lifecycle_state
// disagrees with sequencing_rules.lifecycle_state. Should always be
// zero during dual-write.
MismatchedLifecycle int `json:"mismatched_lifecycle"`
// MismatchedActive — same shape, for is_active.
MismatchedActive int `json:"mismatched_active"`
}
// HasDrift returns true if any field signals divergence between the
// legacy and projected sides. Used by the drift-check ticker to decide
// whether to log at WARN (drift) or INFO (clean).
func (r DualWriteDriftReport) HasDrift() bool {
if r.SequencingRules != r.DeadlineRules {
return true
}
if r.ProceduralEvents != r.ExpectedPE {
return true
}
if r.LegalSources != r.ExpectedLegalSources {
return true
}
if r.MissingSR != 0 || r.OrphanedSR != 0 {
return true
}
if r.MismatchedLifecycle != 0 || r.MismatchedActive != 0 {
return true
}
return false
}
// CheckDualWriteDrift compares the legacy paliad.deadline_rules table
// against the parallel new tables maintained by Slice B.2's dual-write.
// Returns a DualWriteDriftReport — caller decides what to do with
// non-zero drift (log, page, fail healthcheck, etc.).
//
// Read-only. Safe to run against prod. Single query per metric so the
// pool isn't held for a long time. No locks; tolerates concurrent
// writes (counts may shift by one or two during the read, but a
// persistent drift > 0 is the alarm signal).
func CheckDualWriteDrift(ctx context.Context, conn *sqlx.DB) (*DualWriteDriftReport, error) {
var r DualWriteDriftReport
q := func(label, sql string, dst *int) error {
if err := conn.GetContext(ctx, dst, sql); err != nil {
return fmt.Errorf("drift-check %s: %w", label, err)
}
return nil
}
if err := q("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &r.DeadlineRules); err != nil {
return nil, err
}
if err := q("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &r.SequencingRules); err != nil {
return nil, err
}
if err := q("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &r.ProceduralEvents); err != nil {
return nil, err
}
if err := q("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &r.LegalSources); err != nil {
return nil, err
}
if err := q("expected_pe", `
SELECT
(SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL)
+
(SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL)
`, &r.ExpectedPE); err != nil {
return nil, err
}
if err := q("expected_ls",
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
&r.ExpectedLegalSources); err != nil {
return nil, err
}
if err := q("missing_sr", `
SELECT COUNT(*) FROM paliad.deadline_rules dr
LEFT JOIN paliad.sequencing_rules sr ON sr.id = dr.id
WHERE sr.id IS NULL`, &r.MissingSR); err != nil {
return nil, err
}
if err := q("orphaned_sr", `
SELECT COUNT(*) FROM paliad.sequencing_rules sr
LEFT JOIN paliad.deadline_rules dr ON dr.id = sr.id
WHERE dr.id IS NULL`, &r.OrphanedSR); err != nil {
return nil, err
}
if err := q("mismatched_lifecycle", `
SELECT COUNT(*) FROM paliad.deadline_rules dr
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
WHERE dr.lifecycle_state <> sr.lifecycle_state`, &r.MismatchedLifecycle); err != nil {
return nil, err
}
if err := q("mismatched_active", `
SELECT COUNT(*) FROM paliad.deadline_rules dr
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
WHERE dr.is_active <> sr.is_active`, &r.MismatchedActive); err != nil {
return nil, err
}
return &r, nil
}
// StartDualWriteDriftCheckLoop runs CheckDualWriteDrift on a fixed
// interval for the lifetime of ctx. A clean run logs at INFO level;
// drift logs at WARN level with the full report payload. The first
// check fires after `interval`, not immediately on Start — by the time
// the ticker first fires the process has finished booting and the
// initial backfill + dual-write writes have settled.
//
// Slice B.2 (t-paliad-305). interval should be short enough to surface
// drift before the next deploy (so a broken dual-write doesn't sit
// silent for a week) and long enough to avoid noise (the check holds
// no locks but it does run nine SELECT COUNTs).
//
// Recommended interval: 6h. Override via the caller (cmd/server picks
// the runtime value).
func StartDualWriteDriftCheckLoop(ctx context.Context, conn *sqlx.DB, interval time.Duration) {
if interval <= 0 {
interval = 6 * time.Hour
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
report, err := CheckDualWriteDrift(ctx, conn)
if err != nil {
log.Printf("dual-write drift-check: error: %v", err)
continue
}
if report.HasDrift() {
log.Printf("dual-write drift-check: DRIFT DETECTED — "+
"deadline_rules=%d sequencing_rules=%d "+
"procedural_events=%d (expected %d) "+
"legal_sources=%d (expected %d) "+
"missing_sr=%d orphaned_sr=%d "+
"mismatched_lifecycle=%d mismatched_active=%d",
report.DeadlineRules, report.SequencingRules,
report.ProceduralEvents, report.ExpectedPE,
report.LegalSources, report.ExpectedLegalSources,
report.MissingSR, report.OrphanedSR,
report.MismatchedLifecycle, report.MismatchedActive)
} else {
log.Printf("dual-write drift-check: OK — "+
"deadline_rules=%d sequencing_rules=%d "+
"procedural_events=%d legal_sources=%d",
report.DeadlineRules, report.SequencingRules,
report.ProceduralEvents, report.LegalSources)
}
}
}
}()
}

View File

@@ -0,0 +1,300 @@
// 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")
}
// 5. Drift check should return zero drift right after the dance.
report, err := CheckDualWriteDrift(ctx, pool)
if err != nil {
t.Fatalf("CheckDualWriteDrift: %v", err)
}
if report.HasDrift() {
t.Errorf("CheckDualWriteDrift unexpectedly flagged drift: %+v", report)
}
}
// 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

@@ -46,6 +46,7 @@ import (
"encoding/csv"
"fmt"
"io"
"log/slog"
"regexp"
"sort"
"strings"
@@ -297,7 +298,10 @@ func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSp
// is just bookkeeping that releases the snapshot.
defer func() { _ = tx.Rollback() }()
sheets := orgSheetQueries()
sheets, err := resolveOrgSheets(ctx, tx, orgSheetSpecs())
if err != nil {
return meta, err
}
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
return meta, err
}
@@ -1138,7 +1142,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 +1522,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`},
@@ -1560,73 +1564,249 @@ SELECT 'partner_unit_default'::text AS source,
// secret|token|password|api_key|private_key on every sheet as a
// belt-and-braces filter. user_caldav_config.password_encrypted is
// explicitly named in DropColumns too.
func orgSheetQueries() []sheetQuery {
return []sheetQuery{
//
// Drift-resistance (m/paliad#140): each spec declares its desired
// ORDER BY columns as a list. At backup time the exporter probes
// information_schema.columns for the live schema; any ORDER BY column
// that no longer exists is dropped (logged WARN). This way a column
// rename or removal never breaks a backup — the worst case is a sheet
// that loses sort stability until the spec is updated. A sheet whose
// ORDER BY columns are all gone still exports, just in pg's natural
// (unspecified) order.
//
// Custom column projections (e.g. documents drops ai_extracted) live
// in the SQL override field; if set, it bypasses the Table+OrderBy
// builder entirely. Use it sparingly — every override re-introduces
// drift risk for that sheet.
// orgSheetSpec declares one org-scope sheet for the drift-resistant
// builder. Either set SQL (free-form override) or set Table+OrderBy
// (let the builder compose `SELECT * FROM <Table> ORDER BY <existing>`).
type orgSheetSpec struct {
// SheetName lands in the workbook sheet and the JSON top-level key.
SheetName string
// Table is schema-qualified (e.g. "paliad.appointments"). Used only
// when SQL is empty. The schema/table form must be valid SQL
// identifiers — the builder splits on the dot, no quoting.
Table string
// OrderBy is the *desired* sort columns. Missing columns are
// dropped silently-with-a-WARN at build time; remaining columns
// keep their declared order. Empty/all-missing → no ORDER BY (still
// deterministic-within-a-snapshot under the REPEATABLE READ tx, but
// the order across runs may differ).
OrderBy []string
// SQL is an explicit override; if non-empty, Table+OrderBy are
// ignored entirely. Use only when the projection cannot be
// expressed as SELECT * (e.g. documents drops the ai_extracted
// jsonb column).
SQL string
// Args are positional arguments. Only meaningful with SQL override;
// the Table+OrderBy path takes no args.
Args []any
// DropColumns is an explicit list of column names to drop from the
// result regardless of the PII deny-regex.
DropColumns []string
}
func orgSheetSpecs() []orgSheetSpec {
return []orgSheetSpec{
// --- entity sheets (alphabetical) ---
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_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`},
{SheetName: "appointment_caldav_targets", Table: "paliad.appointment_caldav_targets", OrderBy: []string{"appointment_id", "binding_id"}},
{SheetName: "appointments", Table: "paliad.appointments", OrderBy: []string{"id"}},
{SheetName: "approval_policies", Table: "paliad.approval_policies", OrderBy: []string{"id"}},
{SheetName: "approval_requests", Table: "paliad.approval_requests", OrderBy: []string{"id"}},
// backups is self-reflexive — including it makes "what backups
// have we taken" recoverable from any prior backup. Tiny table.
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
{SheetName: "backups", Table: "paliad.backups", OrderBy: []string{"started_at", "id"}},
{SheetName: "caldav_sync_log", Table: "paliad.caldav_sync_log", OrderBy: []string{"occurred_at", "id"}},
{SheetName: "checklist_instances", Table: "paliad.checklist_instances", OrderBy: []string{"id"}},
{SheetName: "checklist_shares", Table: "paliad.checklist_shares", OrderBy: []string{"id"}},
{SheetName: "checklists", Table: "paliad.checklists", OrderBy: []string{"id"}},
{SheetName: "deadline_rule_audit", Table: "paliad.deadline_rule_audit", OrderBy: []string{"changed_at", "id"}},
{SheetName: "deadlines", Table: "paliad.deadlines", OrderBy: []string{"id"}},
// documents: ai_extracted jsonb dropped (verbose AI prompts;
// matches the personal/project precedent). Binaries are not in
// the export — only metadata.
// the export — only metadata. Uses SQL override because the
// projection isn't SELECT *.
{
SheetName: "documents",
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
FROM paliad.documents
ORDER BY id`,
},
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
{SheetName: "email_broadcasts", Table: "paliad.email_broadcasts", OrderBy: []string{"id"}},
{SheetName: "email_template_versions", Table: "paliad.email_template_versions", OrderBy: []string{"id"}},
{SheetName: "email_templates", Table: "paliad.email_templates", OrderBy: []string{"key", "lang"}},
{SheetName: "firm_dashboard_default", Table: "paliad.firm_dashboard_default", OrderBy: []string{"id"}},
{SheetName: "invitations", Table: "paliad.invitations", OrderBy: []string{"sent_at", "id"}},
{SheetName: "notes", Table: "paliad.notes", OrderBy: []string{"id"}},
{SheetName: "parties", Table: "paliad.parties", OrderBy: []string{"id"}},
{SheetName: "partner_unit_events", Table: "paliad.partner_unit_events", OrderBy: []string{"id"}},
{SheetName: "partner_unit_members", Table: "paliad.partner_unit_members", OrderBy: []string{"partner_unit_id", "user_id"}},
{SheetName: "partner_units", Table: "paliad.partner_units", OrderBy: []string{"id"}},
{SheetName: "policy_audit_log", Table: "paliad.policy_audit_log", OrderBy: []string{"created_at", "id"}},
{SheetName: "project_events", Table: "paliad.project_events", OrderBy: []string{"id"}},
{SheetName: "project_partner_units", Table: "paliad.project_partner_units", OrderBy: []string{"project_id", "partner_unit_id"}},
{SheetName: "project_teams", Table: "paliad.project_teams", OrderBy: []string{"project_id", "user_id"}},
{SheetName: "projects", Table: "paliad.projects", OrderBy: []string{"id"}},
{SheetName: "reminder_log", Table: "paliad.reminder_log", OrderBy: []string{"sent_at", "id"}},
{SheetName: "submission_drafts", Table: "paliad.submission_drafts", OrderBy: []string{"id"}},
{SheetName: "system_audit_log", Table: "paliad.system_audit_log", OrderBy: []string{"created_at", "id"}},
{
SheetName: "user_caldav_config",
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
Table: "paliad.user_caldav_config",
OrderBy: []string{"user_id"},
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
},
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
{SheetName: "user_calendar_bindings", Table: "paliad.user_calendar_bindings", OrderBy: []string{"user_id", "calendar_path"}},
{SheetName: "user_card_layouts", Table: "paliad.user_card_layouts", OrderBy: []string{"id"}},
{SheetName: "user_dashboard_layouts", Table: "paliad.user_dashboard_layouts", OrderBy: []string{"user_id"}},
{SheetName: "user_pinned_projects", Table: "paliad.user_pinned_projects", OrderBy: []string{"user_id", "project_id"}},
{SheetName: "user_views", Table: "paliad.user_views", OrderBy: []string{"id"}},
{SheetName: "users", Table: "paliad.users", OrderBy: []string{"id"}},
// --- reference data (alphabetical, prefixed ref__) ---
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
{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__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`},
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
{SheetName: "ref__countries", Table: "paliad.countries", OrderBy: []string{"code"}},
{SheetName: "ref__courts", Table: "paliad.courts", OrderBy: []string{"id"}},
{SheetName: "ref__deadline_concept_event_types", Table: "paliad.deadline_concept_event_types", OrderBy: []string{"concept_id", "event_type_id"}},
{SheetName: "ref__deadline_concepts", Table: "paliad.deadline_concepts", OrderBy: []string{"id"}},
{SheetName: "ref__deadline_event_types", Table: "paliad.deadline_event_types", OrderBy: []string{"deadline_id", "event_type_id"}},
{SheetName: "ref__deadline_rules", Table: "paliad.deadline_rules_unified", OrderBy: []string{"id"}},
{SheetName: "ref__event_categories", Table: "paliad.event_categories", OrderBy: []string{"id"}},
{SheetName: "ref__event_category_concepts", Table: "paliad.event_category_concepts", OrderBy: []string{"event_category_id", "concept_id"}},
{SheetName: "ref__event_types", Table: "paliad.event_types", OrderBy: []string{"id"}},
{SheetName: "ref__holidays", Table: "paliad.holidays", OrderBy: []string{"date", "country"}},
{SheetName: "ref__proceeding_types", Table: "paliad.proceeding_types", OrderBy: []string{"id"}},
{SheetName: "ref__trigger_events", Table: "paliad.trigger_events", OrderBy: []string{"id"}},
}
}
// composeOrgSheetSQL turns one orgSheetSpec into the final SQL string,
// using a per-table column set (typically loaded once per backup run
// from information_schema.columns). Returns the SQL and the list of
// ORDER BY columns that were dropped because they don't exist in the
// live schema.
//
// Pure function — no DB access — so the missing-column behaviour is
// unit-testable without a fixture database.
//
// Rules:
// - If spec.SQL is non-empty, return it unchanged (override path).
// - Otherwise build `SELECT * FROM <Table> [ORDER BY <kept-cols>]`.
// - Columns are kept in their declared order; missing ones recorded
// in `dropped` and omitted from ORDER BY.
// - If no ORDER BY columns survive, the ORDER BY clause is omitted.
//
// knownCols maps unqualified table names (e.g. "appointments") to the
// set of columns they have. A table missing from knownCols is treated
// as "no columns known" — every declared ORDER BY column gets dropped.
func composeOrgSheetSQL(spec orgSheetSpec, knownCols map[string]map[string]struct{}) (sqlText string, dropped []string) {
if spec.SQL != "" {
return spec.SQL, nil
}
unqualified := spec.Table
if i := strings.IndexByte(unqualified, '.'); i >= 0 {
unqualified = unqualified[i+1:]
}
cols := knownCols[unqualified]
kept := make([]string, 0, len(spec.OrderBy))
for _, c := range spec.OrderBy {
if _, ok := cols[c]; ok {
kept = append(kept, c)
} else {
dropped = append(dropped, c)
}
}
var b strings.Builder
b.WriteString("SELECT * FROM ")
b.WriteString(spec.Table)
if len(kept) > 0 {
b.WriteString(" ORDER BY ")
b.WriteString(strings.Join(kept, ", "))
}
return b.String(), dropped
}
// loadOrgSheetColumns probes information_schema.columns once for every
// table referenced by Table+OrderBy specs. Returns a lookup
// {table_name → {column_name → {}}} restricted to the paliad schema.
//
// The queryer is whatever runs the backup's read snapshot — typically
// the REPEATABLE READ tx opened in WriteOrg, so the schema snapshot
// matches the row snapshot.
func loadOrgSheetColumns(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) (map[string]map[string]struct{}, error) {
tableSet := map[string]struct{}{}
for _, sp := range specs {
if sp.Table == "" {
continue // SQL-override sheets carry their own column refs
}
t := sp.Table
if i := strings.IndexByte(t, '.'); i >= 0 {
t = t[i+1:]
}
tableSet[t] = struct{}{}
}
if len(tableSet) == 0 {
return map[string]map[string]struct{}{}, nil
}
tables := make([]string, 0, len(tableSet))
for t := range tableSet {
tables = append(tables, t)
}
rows, err := queryer.QueryxContext(ctx, `
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = ANY($1)
`, tables)
if err != nil {
return nil, fmt.Errorf("probe paliad columns: %w", err)
}
defer rows.Close()
out := make(map[string]map[string]struct{}, len(tableSet))
for rows.Next() {
var table, column string
if err := rows.Scan(&table, &column); err != nil {
return nil, fmt.Errorf("scan paliad columns: %w", err)
}
set, ok := out[table]
if !ok {
set = map[string]struct{}{}
out[table] = set
}
set[column] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate paliad columns: %w", err)
}
return out, nil
}
// resolveOrgSheets materialises an org-scope spec list into the
// concrete []sheetQuery that writeBundle expects. Composes each
// spec's SQL via composeOrgSheetSQL using a schema snapshot loaded
// from the same queryer. Logs WARN per dropped ORDER BY column.
func resolveOrgSheets(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) ([]sheetQuery, error) {
knownCols, err := loadOrgSheetColumns(ctx, queryer, specs)
if err != nil {
return nil, err
}
out := make([]sheetQuery, 0, len(specs))
for _, sp := range specs {
sqlText, dropped := composeOrgSheetSQL(sp, knownCols)
for _, c := range dropped {
slog.Warn("backup: ORDER BY column dropped (not in schema)",
"sheet", sp.SheetName,
"table", sp.Table,
"column", c,
)
}
out = append(out, sheetQuery{
SheetName: sp.SheetName,
SQL: sqlText,
Args: sp.Args,
DropColumns: sp.DropColumns,
})
}
return out, nil
}

View File

@@ -169,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
@@ -200,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) {
@@ -311,7 +311,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe
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 dr
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`
@@ -516,6 +516,61 @@ func computeDepths(
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

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

View File

@@ -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)

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)
}
@@ -221,6 +221,12 @@ func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUI
); err != nil {
return fmt.Errorf("set deadline rule_id: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): mirror the new linkage onto
// the parallel deadlines.procedural_event_id + sequencing_rule_id
// columns so they don't drift from rule_id.
if err := syncDeadlineDualLinks(ctx, tx, oc.DeadlineID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rule_backfill_orphans
SET resolved_at = $1,

View File

@@ -209,6 +209,14 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
return nil, fmt.Errorf("insert rule: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): project the new row into
// legal_sources / procedural_events / sequencing_rules in the same
// transaction so the parallel tables stay in lock-step with
// deadline_rules through the B.3 read-cutover window.
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create: %w", err)
}
@@ -276,6 +284,10 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return nil, fmt.Errorf("update rule draft: %w", err)
}
// Slice B.2 dual-write (t-paliad-305).
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update: %w", err)
}
@@ -336,6 +348,14 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
); err != nil {
return nil, fmt.Errorf("clone rule as draft: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): new draft gets its own
// procedural_events + sequencing_rules row. The synthetic-code
// branch fires here when the source rule had NULL submission_code
// (the clone inherits the NULL and mints a fresh 'null.<8hex>'
// derived from newID).
if err := syncDualWriteFromDeadlineRule(ctx, tx, newID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit clone: %w", err)
}
@@ -392,6 +412,18 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
}
}
// Slice B.2 dual-write (t-paliad-305): sync both sides — the newly
// published draft AND the cloned-from peer that just flipped to
// archived (if any).
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if current.DraftOf != nil {
if err := syncDualWriteFromDeadlineRule(ctx, tx, *current.DraftOf); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit publish: %w", err)
}
@@ -459,6 +491,12 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
}
}
// Slice B.2 dual-write (t-paliad-305): mirror the lifecycle flip
// onto sequencing_rules + procedural_events.
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit flip: %w", err)
}
@@ -598,7 +636,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)
@@ -618,7 +656,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
}
@@ -677,7 +715,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

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,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.
//
@@ -59,4 +63,17 @@ type Catalog interface {
// (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

@@ -292,6 +292,20 @@ func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxe
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)

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,
@@ -671,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
@@ -940,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,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

@@ -430,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