Collapses the 3 UPC appeal proceeding_types (upc.apl.merits 7 rules,
upc.apl.cost 2, upc.apl.order 7 = 16 total across 3 codes) into ONE
unified upc.apl proceeding type + a per-rule applies_to_target[]
discriminator. The verfahrensablauf picker now shows one "Berufung"
tile; after picking it, the user selects which decision the appeal is
directed AT via a 5-chip group (Endentscheidung / Kostenentscheidung /
Anordnung / Schadensbemessung / Bucheinsicht) and the engine filters
rules whose applies_to_target contains the picked slug.
m's 2026-05-26 decision: Schadensbemessung-as-appeal is a NEW first-
class target with its OWN rule set (no shared inheritance from
merits). The 5 enum values are all defined + addressable; for now
schadensbemessung and bucheinsicht return empty timelines until rules
are seeded in a follow-up slice (likely via /admin/rules or pairing
with t-paliad-193 orphan-concept-seed).
Migration 134 (additive only):
- ADD proceeding_types.appeal_target text (CHECK on 5 slugs OR NULL)
- ADD deadline_rules.applies_to_target text[] (CHECK each element
in the 5 slugs)
- INSERT the unified upc.apl row (inherits sort/color from
upc.apl.merits)
- Audit-first RAISE NOTICE pass listing every row about to be
touched + a post-migration sanity check
- Reassign rule rows: merits → applies_to_target={endentscheidung},
cost → {kostenentscheidung}, order → {anordnung}
- Archive (is_active=false, NOT DELETE) the 3 old proceeding_types
so historical FKs stay intact
- Down migration restores is_active=true on the 3 old types, points
rules back by their applies_to_target stamp, drops the unified
row, drops both columns. Safe.
Package additions (pkg/litigationplanner):
- AppealTarget* constants + AppealTargets[] ordered list +
IsValidAppealTarget(s) predicate (silent no-op on unknown slugs
so a stale frontend chip doesn't break the render)
- ProceedingType.AppealTarget *string field (top-level marker;
NULL on non-appeal proceedings)
- Rule.AppliesToTarget pq.StringArray field (per-row applies-to set)
- CalcOptions.AppealTarget string (engine filter — when set,
keeps only rules whose AppliesToTarget contains the slug)
Engine filter runs after ApplyRuleOverrides but before the rule walk
so the existing condition_expr / spawn / appellant-context machinery
operates on the filtered subset transparently.
paliad-side wiring:
- deadline_rule_service.go: ruleColumns + proceedingTypeColumns
extended to scan the new columns
- handlers/fristenrechner.go: AppealTarget JSON field on the
request payload, threaded into CalcOptions
Frontend (verfahrensablauf surface only):
- Single "Berufung" tile replaces the 3 separate Berufung tiles
- New 5-chip appeal-target row, shown only when upc.apl is picked
- URL state ?target=<slug>; default endentscheidung when none set
- APPELLANT_AXIS_PROCEEDINGS updated: upc.apl.* (3 entries) →
upc.apl (1 entry)
- i18n keys (DE + EN) for the new tile + the 5 chip labels +
the "Worauf richtet sich die Berufung?" / "Appeal against:" prompt
- calculateDeadlines threads appealTarget through to the API
Acceptance:
- go build clean, go test all green (existing test suite — no new
tests on the engine filter as a follow-up; the migration's
sanity-check DO block guards the rule-reassignment count)
- Live audit before drafting confirmed: 3 active UPC appeal
proceeding_types, 16 rules total, primary_party already conforms
to 4-value vocab on all proceeding-bound rules
Atomic extraction of the deadline-rule compute engine + types from
internal/services into a new pkg/litigationplanner package that paliad
+ youpc.org can both import. No behaviour change — every existing test
passes against the post-move shape.
Package contents (~1850 LoC):
- doc.go package docstring + reuse manifesto
- types.go Rule, ProceedingType, NullableJSON, AdjustmentReason,
HolidayDTO, CalcOptions, CalcRuleParams, Timeline,
TimelineEntry, RuleCalculation*, FristenrechnerType,
ProjectHint, sentinel errors
- catalog.go Catalog interface (proceeding + rule lookups)
- holidays.go HolidayCalendar interface
- courts.go CourtRegistry interface + DefaultsForJurisdiction +
country/regime constants
- expr.go EvalConditionExpr + HasConditionExpr +
ExtractFlagsFromExpr (jsonb gate evaluator)
- durations.go ApplyDuration + AddWorkingDays (pure compute)
- subtrack.go SubTrackRouting + LookupSubTrackRouting registry
- legal_source.go FormatLegalSourceDisplay + BuildLegalSourceURL
- proceeding_mapping.go MapLitigationToFristenrechner + code constants
(CodeUPCInfringement, CodeDEInfringementLG, ...)
- engine.go Calculate + CalculateRule + the trigger-event
branch + applyRuleOverrides (the big move)
paliad side (~1900 LoC net deletion):
- internal/services/fristenrechner.go shrinks from 1505 → ~290 lines
(thin paliad Catalog adapter + type aliases for back-compat).
- internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON
become type aliases to litigationplanner.* — every sqlx scan and
every projection_service caller compiles unchanged.
- internal/services/holidays.go: AdjustmentReason + HolidayDTO become
aliases to lp.* (canonical definitions now in the package).
- internal/services/proceeding_mapping.go: rewritten as thin re-exports
of lp constants + helpers.
- internal/services/deadline_search_service.go: FormatLegalSourceDisplay
+ BuildLegalSourceURL replaced with delegating wrappers to lp.
Catalog interface satisfaction:
- DeadlineRuleService → paliadCatalog adapter (wraps the existing
service, replicates the original SELECT shapes).
- HolidayService → satisfies lp.HolidayCalendar directly (compile-
time assertion at end of fristenrechner.go).
- CourtService → satisfies lp.CourtRegistry directly.
Wire shape is byte-identical. JSON tags on Rule / ProceedingType /
Timeline / TimelineEntry / RuleCalculation match the historical
UIResponse / UIDeadline shape; the frontend reads the same bytes.
Slice B (Catalog interface + paliad loader cleanup) is folded into
this commit since Slice A already needs the interfaces to call
Calculate across the boundary. Slice C (embedded UPC snapshot +
generator) is the next coder shift; the Berufung unification m
called out lands in Slice B/C per head's brief.
Refs: docs/design-litigation-planner-2026-05-26.md
m's 2026-05-26 decisions:
- Q1 composition: primary+spawned (v1) with multi-proceeding peer compose as v2 goal — jsonb spec architected for N entries from day 1
- Q2 scope: per-project + abstract (project_id NULL = abstract saved templates)
- Q3 dates: per-anchor overrides over one base date (matches today's compute)
- Q4 storage: new paliad.scenarios table with jsonb spec (NOT project_event_choices column extension)
- "users should not add their own rules" — original Slice E (user-authored rules) DROPPED, replaced with abstract scenarios surface on /tools/verfahrensablauf
§5 rewritten with new schema (paliad.scenarios + active_scenario_id FK), jsonb spec shape (proceedings[] array, version-tagged), validate-on-load discipline, multi-peer v2 path. §6 struck-through with original body preserved as historical context. §10 slice plan revised: Slice E = abstract scenarios surface, not user-authored rules. §0.5 added with decision matrix; §13 marked resolved.
Package shape (§2 §3) unchanged — library was decoupled from persistence/UI choices by design.
Inventor design for m/paliad#124. Atomic extract of FristenrechnerService /
DeadlineCalculator / proceeding_mapping / SubTrackRoutings / legal-source
helpers into pkg/litigationplanner with Catalog / HolidayCalendar /
CourtRegistry interfaces. youpc.org reuse via embedded UPC snapshot
(catalog.json + holidays.json + courts.json) shipped inside the package.
6 slices: A extract, B catalog interface, C embedded snapshot + generator,
D scenarios persistence (project_event_choices.scenario_name), E
user-authored rules (deadline_rules.project_id), F youpc-side PR.
Q1 + Q2 (material) escalated to head per inventor protocol — NOT
AskUserQuestion. Q3-Q5 locked. Decision picks (R) noted; doc holds together
under any answer to the open Qs because pkg shape is decoupled from
persistence choices.
m/paliad#127 — m's correction to #88. The user-perspective labels
"Unsere Seite" / "Gegnerseite" only make sense once the user has picked
a side; while side === null (Nicht festgelegt, the default after #120)
the column headers fall back to the semantic-neutral pair
"Proaktiv" / "Reaktiv". Picking a side re-enables the #88 labels.
renderColumnsBody now branches the leftLabel / rightLabel pair on the
incoming side. Bucketing primitive untouched: column placement is
unchanged, only the column-header text differs.
New i18n keys deadlines.col.proactive / deadlines.col.reactive (DE +
EN). The label fallback is documented inline in
verfahrensablauf-core.ts so a future reader sees why the columns have
two header modes.
Tests: four renderColumnsBody assertions covering side=null (explicit
+ default), side=claimant, side=defendant. Existing bucketing tests
unchanged.
Workflow shifted to hand-written numbered migrations; the audit-row SQL
export tool no longer has any consumers. Pure deletion — /admin/rules
and /admin/rules/{id}/edit stay; only the export-to-SQL flow goes.
Deleted:
- frontend/src/admin-rules-export.tsx
- frontend/src/client/admin-rules-export.ts
Removed:
- routes GET /admin/rules/export and GET /admin/api/rules/export-migrations
- handleAdminExportRuleMigrations + handleAdminRulesExportPage
- RuleEditorService.ExportMigrationsSince + ExportResult + sqlEscape helper
- build.ts entries (import, client bundle, dist HTML write)
- Sidebar "Regel-Migrations" nav item + "Migrations exportieren" button on /admin/rules
- all admin.rules.export.* + nav.admin.rules_export + admin.rules.list.export i18n keys (DE+EN)
- .admin-rules-export-* CSS rules (dead after page deletion)
Doc references in design-fristen-phase2-2026-05-15.md and
design-paliad-data-export-2026-05-19.md updated to mark the endpoint as
removed (acceptance #2 requires grep to return zero hits).
Optional events anchored on the same trigger (e.g. the four
post-Entscheidung rules in upc.inf.cfi) used to render in catalog
sequence_order, so a 2-month rule (R.118.4 Folgeentscheidungen)
would precede a 1-month rule (R.151 Kostenentscheidung) chained
off the same decision. Now the calculator does a post-evaluation
permutation pass that sorts consecutive same-parent rows by
duration ascending — days < weeks < months < years, ties broken
by duration_value then submission_code.
Different trigger groups keep their proceeding-sequence position
— the walk only ever permutes rows that already share a parent.
Root rules (no parent) are never sorted against each other.
Court-set / conditional rows whose date isn't in the duration
ladder sort LAST within their group.
Verified order against m's report: R.151 cost_app + R.353
rectification (1-month tier) now render before R.220.1
appeal_spawn + R.118.4 cons_orders (2-month tier).
Issue: m/paliad#128
t-paliad-294 / m/paliad#126. knuth's #121 conditional-rendering
defaulted the "abhängig von <parent>" chip to the rule's parent_id
display name. For R.262(2) Erwiderung auf Vertraulichkeitsantrag the
parent_id resolves to the SoC (Klageerhebung), but the rule's real
semantic anchor is the opposing party's confidentiality application
(paliad.trigger_events id=25). The chip read "abhängig von
Klageerhebung", which is wrong.
Fix: when a rule has a non-NULL trigger_event_id, the engine stamps
ParentRuleCode / ParentRuleName / ParentRuleNameEN from the
trigger_events catalog row instead of from the parent_id chain. The
parent_id stays as the calc-time arithmetic anchor — only the user-
facing dependency identity shifts.
Generalises across every rule with a real trigger_event_id (2 rows
in the live corpus today: confidentiality_response and
translations_lodge — both relabel correctly).
Touches both surfaces in one shot: verfahrensablauf-core's chip
("abhängig von …") and shape-timeline's "Folgt aus …" footer both
read from ParentRule*, so no frontend change needed.
Tests: extend TestUIDeadline_IsConditional_UncertainAnchors with a
DE+EN string-pinning case for R.262(2) plus a generalisation guard
for translations_lodge. Negative guard asserts the chip no longer
leaks "Klageerhebung" / "Statement of Claim".
m/paliad#125 — concern A (horizontal scroll) and concern B (compact
event-card UX).
Concern A: the inline "Wieder einblenden" chip from t-paliad-290 pushed
hidden cards past their column width on 375/414/768, causing horizontal
page scroll. Fix: drop the chip entirely; surface the un-hide as a
prominent "Wieder einblenden" entry inside the caret popover (matches
the m's "actions live in the caret menu" framing). The card title row
now also wraps + shrinks (flex-wrap + min-width:0 + overflow-wrap)
so no inline child can ever blow the row width.
Concern B (the bigger UX): cards now speak m's "cut the tree of
possibilities" vocabulary via iconified state markers in the title row:
- Optional event → ⊙ (timeline-state-icon--optional)
- Hidden by user → 👁⃠ (timeline-state-icon--hidden)
- Conditional anchor → already covered by the "abhängig von <parent>"
chip on the date column (t-paliad-289); no duplicate marker.
- CCR-included / appellant picks → already on the per-card chip.
The legacy `.optional-badge` text chip and `.event-card-choices-unhide`
inline chip are gone — both replaced by the icon language + popover
entry.
Renderer wires the unhide path with two contracts:
- data-is-hidden="1" on the caret button when isHidden=true, so the
popover knows to render the prominent unhide block on top.
- Defensive fallback: if a rule's choices_offered was edited away
after the user had already saved skip=true (so isHidden=true but
choicesOffered is empty), the renderer synthesizes {skip:[true,
false]} so the popover still has an un-hide path.
CSS:
- .timeline-item min-height 4rem → 2.75rem (less vertical air).
- .timeline-content padding-bottom 1rem → 0.6rem (tighter gutter).
- .timeline-item-header gains flex-wrap + min-width:0.
- .timeline-name gains min-width:0 + overflow-wrap:anywhere
(long German compounds wrap mid-word instead of overflowing).
- New: .timeline-state-icon[--optional|--hidden] icon-style markers.
- New: .event-card-choices-unhide-btn — prominent full-width lime
pill inside the popover, midnight-text in both themes (matches
the active-option pin from m/paliad#123).
i18n:
- state.optional.tooltip — "Optionales Ereignis" / "Optional event"
- state.hidden.tooltip — "Ausgeblendet — über Optionen-Menü wieder
einblenden" / "Hidden — restore via the options menu"
- choices.unhide.chip kept (now used as the popover button label).
Tests: 27 → 29 tests in verfahrensablauf-core.test.ts. Old isHidden
inline-chip cases replaced by state-icon + caret-data-is-hidden
contract cases. Added defensive-fallback case for the synthesized
skip offer. Added regression guard that the legacy
.event-card-choices-unhide class is no longer emitted. Added
optional-priority → ⊙ icon contract pair.
Hard rules respected:
- Title + date + Rule citation unchanged (m likes these).
- Click-to-edit on date span (.frist-date-edit) untouched.
- Conditional rendering (t-paliad-289 chip + dotted border) untouched.
- Per-card actions (skip, appellant pick, include-CCR, unhide) all
reachable via the caret popover.
go build ./... && go test ./internal/... && cd frontend && bun run
build && bun test — all green (181 tests).
Rules anchored on uncertain triggers (R.109 backward-anchor without
oral-hearing date; R.118(4) without validity decision; R.262(2)
without recorded Vertraulichkeitsantrag) previously rendered concrete
dates fabricated off the trigger date. Add IsConditional projection
flag so the SmartTimeline + Verfahrensablauf surfaces "abhängig von
<parent>" instead of a misleading date.
Backend (fristenrechner.go):
- Add IsConditional + ParentRuleCode/Name/NameEN to UIDeadline.
- Pre-pass populates courtSet from rule.is_court_set=true BEFORE the
main loop, so order-of-evaluation in sequence_order no longer matters
for the parent-court-set check. Fixes R.109(1) "Antrag auf
Simultanübersetzung" (sequence_order=45 < Mündliche Verhandlung's
sequence_order=50): the timing='before' backward arithmetic was
computing 1 month before the trigger date because the court-set
parent hadn't been classified yet.
- Set IsConditional=true on every IsCourtSetIndirect branch (catches
R.109 backward + R.118(4) cons_orders chain off the decision).
- Set IsConditional=true for priority='optional' + primary_party='both'
rules whose data-model parent is the trigger anchor (covers R.262(2)
confidentiality_response: the data anchors on SoC, but the real
trigger is the opposing party's confidentiality motion which may
never happen). Suppressed by IsOverridden so user anchors win.
Backend (projection_service.go):
- Add IsConditional to TimelineEvent + propagate from UIDeadline.
- New Status="conditional" for projected rows; clears Date, populates
DependsOnRuleCode/Name from UIDeadline.ParentRule* so the row
carries the "abhängig von <parent>" payload even when the parent
has no computed date for annotateDependsOn to discover.
Frontend (verfahrensablauf-core.ts + CSS + i18n):
- CalculatedDeadline gains isConditional + parentRule* fields.
- deadlineCardHtml renders "abhängig von <parent>" chip with
click-to-edit affordance in place of the date column when
isConditional=true. IsConditional wins over IsCourtSet for the
date column (they overlap; "abhängig von <parent>" names the
specific blocker).
- .timeline-item--conditional / .fr-col-item--conditional CSS:
dotted border + faded text so the conditional state reads at glance.
- Replaced escHtml's DOM-backed implementation with a pure-JS regex
escape so the module is testable in bun test without jsdom (the
old form forced fixtures to leave several fields empty just to
avoid the DOM dependency).
Tests:
- TestApplyLookaheadCap_ConditionalRowsPassThrough: pure-function lock
that conditional rows pass through applyLookaheadCap untouched
(don't count against ProjectedTotal/Shown, don't get capped).
- TestUIDeadline_IsConditional_UncertainAnchors (TEST_DATABASE_URL):
asserts R.109(1)/(4), R.118(4) chain, and R.262(2) all render
IsConditional=true with empty DueDate + populated ParentRule*; SoD
stays non-conditional; override on the oral hearing flips R.109(1)
back to concrete date.
- 4 new bun tests for the conditional rendering branches in
deadlineCardHtml.
UX path verified by tests + manual review of the live rule corpus:
opening a UPC inf project without oral-hearing date now surfaces
R.109(1) + R.109(4) as conditional; recording the Vertraulichkeitsantrag
(anchoring R.262(2) via the existing "Datum setzen" flow) flips it
back to a concrete date.
go build / go test / bun test / bun run build all clean.
Six surfaces paired a lime background with var(--color-text), which
flips to cream in dark mode and collapses contrast on the high-luminance
brand lime. Switch them to var(--color-accent-dark) — the design token
already defined to stay midnight in both themes as the WCAG-AA fg on
lime.
Affected:
- .event-card-choices-option--active (Berufung durch … popover —
m's primary report on m/paliad#123)
- .fristen-row.is-active .fristen-row-num
- .form-hint-badge
- .paliadin-widget-send-btn
- .smart-timeline-anchor-submit
- .admin-rules-chip.active
Lime hue and non-active states untouched.
Refs: m/paliad#123
Restructures the submission-draft sidebar per m's m/paliad#119 review.
Three changes on the variable form (Part B):
- VARIABLE_GROUPS collapses into four lawyer-facing sections: Mandant
& Verfahren (firm.* + project.* + procedural_event.*), Parteien
(manual {{parties.<role>.*}} overrides), Frist (the now-internal
deadline.* block, COLLAPSED by default since the skeletons no
longer render it), Sonstiges (today.* / user.* trim).
- Group sections are click-to-collapse via a sticky state map; the
Frist + Parteien-override sections open closed so the visible form
stays tight on first load.
- The legacy {{rule.*}} aliases drop off the sidebar — still resolved
by SubmissionVarsService for old templates, no longer surfaced as
override rows (they cluttered the form and the canonical
procedural_event.* names cover the same ground).
Multi-party + Add Party (Part C):
- The party picker now renders all three role buckets (claimants /
defendants / others) even when empty, so the lawyer can populate via
Add Party. The block is hidden only when no project is attached.
- Each side gets a "+ Partei hinzufügen (Klägerseite / Beklagtenseite
/ Weitere Parteien)" button that opens an inline panel with two
tabs:
- Manual entry — name, role (pre-filled from side), representative.
Submits to POST /api/projects/{id}/parties, creating a real
paliad.parties row that immediately surfaces in available_parties.
- Aus DB übernehmen — debounced (200ms) search against the new
GET /api/parties/search endpoint. Returns hits across every
visible project with project_title + reference for context.
Already-on-this-project rows are filtered out client-side. Picking
a hit clones name/role/representative into a fresh row on the
current project — the simplest semantics that survives the
paliad.parties.project_id NOT NULL contract while honouring m's
"no manual re-typing" requirement.
- Newly-added parties land in selected_parties immediately so the new
party is rendered in the next preview round-trip without an extra
click. Implicit-"all" default is preserved (empty selected_parties
still means "every party on the project, including this new one").
- Search-result repaints reach only into the <ul>, not the whole
picker — keeps focus + selection on the search input across
keystrokes.
CSS:
- Collapsible-section caret rotation, busy/disabled form states, tab
highlights, DB-picker result rows with project chip + hover, all
inherit the existing lime-tint accent so the new affordances look
native to the editor.
TSX:
- Comment update on the parties block; no structural change. The
bilingual hint copy in i18n.ts now nudges towards Add Party.
Adds PartyService.Search returning paliad.parties rows from every
project the caller can see, matched by case-insensitive substring on
name or representative. Wired via GET /api/parties/search?q=... — used
by the submission-draft Add-Party panel's "Aus DB übernehmen" tab.
Visibility flows through the same visibilityPredicatePositional helper
every project-scoped read uses; invisible projects' parties never
surface. Capped at 25 hits per call (no pagination — typical lookup is
"the party I'm thinking of by name", not a browse).
Result shape carries project_title + project_reference so the picker
can disambiguate identically-named parties across cases.
Per m's m/paliad#119 report: the {{deadline.*}} block was leaking
internal/admin context (Frist-Bezeichnung, Fälligkeit, "berechnet aus",
Quelle) into court-bound submissions. The dedicated Frist heading and
its 4 body lines are removed from both gen-skeleton-submission-template
(_skeleton.docx) and gen-hl-skeleton-template (_firm-skeleton.docx).
The {{deadline.due_date_long_en}} reference in the locale-aware
verification footer is also dropped. {{deadline.*}} placeholders stay
resolvable in SubmissionVarsService — a custom template can still pick
them up — but the default skeletons no longer render them in the body.
Regenerated .docx files uploaded to HL/mWorkRepo:
- 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx → d0ecc0e
- 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx → 25954c9
m/paliad#122. atlas's #96 Slice A added per-card 'Überspringen' but no
un-skip path — hidden cards just disappeared from the timeline. This
adds the missing return path:
- CalcOptions.IncludeHidden (default false) tells the calculator to
re-surface skipRules entries as faded rows instead of dropping them.
When true, the rule renders with UIDeadline.IsHidden=true and the
descendant-suppression cascade is bypassed so children compute their
dates off the un-suppressed parent.
- UIResponse.HiddenCount always reflects the projection's hide count
(gate-passed rules whose submission_code is in skipRules) so the
"Ausgeblendete (N)" badge stays accurate regardless of toggle state.
- /tools/verfahrensablauf gets a "Ausgeblendete anzeigen" checkbox next
to the perspective + appellant selectors. URL-driven (?show_hidden=1)
so the state is shareable and survives reload. The row hides itself
on projections with zero hidden cards.
- Hidden cards render via .timeline-item--hidden / .fr-col-item--hidden
(opacity 0.55 + dotted border, mirroring the existing
--skipped fade) and carry an inline "Wieder einblenden" chip. Clicking
the chip removes the skip choice via the page's existing
attachEventCardChoices remove callback (URL state + recalc included)
and runs through a new delegated handler in event-card-choices.ts.
- 3 new i18n keys (DE+EN): choices.show_hidden.label,
choices.show_hidden.count, choices.unhide.chip.
The skip-choice storage shape (paliad.project_event_choices, atlas's
table) is unchanged — un-hide is just a delete of the skip row.
Tests: 3 new bun-test cases pin the chip contract (emits on isHidden=
true with submission_code, suppressed otherwise); go test ./internal/...
+ bun run build clean.
The Verfahrensablauf side selector offered Klägerseite / Beklagtenseite /
Beide. 'Beide' is legally impossible (no party is on both sides) — the
state being modelled is "perspective not yet picked", not "both sides".
Rename the chip to 'Nicht festgelegt' (DE) / 'Undefined' (EN) without
changing the underlying state value or projection behaviour.
- frontend/src/verfahrensablauf.tsx: chip label flips to
deadlines.side.undefined; add inline hint chip
"Wählen Sie eine Seite, um die Spalten zu fokussieren." next to the
radio cluster, shown only while no side is picked.
- frontend/src/client/verfahrensablauf.ts: sideLabelI18n() returns the
new key for null; syncSideHintVisibility() toggles hint display from
initPerspectiveControls, the side-radio change handler, and
showSideRadioCluster (chip→radio override path).
- frontend/src/client/i18n.ts: rename deadlines.side.both →
deadlines.side.undefined (DE: Nicht festgelegt, EN: Undefined); add
deadlines.side.hint in both languages.
- frontend/src/i18n-keys.ts: rename in the union, keep alphabetical
order.
- frontend/src/styles/global.css: .side-radio-cluster becomes inline-flex
so the hint sits next to the toggle; .side-hint styled muted+italic.
URL backward-compat: ?side=both is already silently treated as null by
readSideFromURL (only accepts claimant|defendant) — same column
behaviour as before, no migration needed. projects.field.our_side.both
is a different concept (a project being a multi-party participant) and
stays untouched.
Tests: 17/17 in verfahrensablauf-core.test.ts still pass; the
"default (no opts) mirrors 'both' rules into ours AND opponent" case
already covers the unchanged null-side projection. Go build + tests
clean. Frontend build clean (i18n scan: 2901 keys, data-i18n
attributes clean).
m/paliad#120
The bar's chip clicks POST a payload shaped as `predicates: {<source>:
<per-source>}` — flat, one entry per data source. Go declared
`Predicates map[DataSource]Predicates` — a doubled-nested wrapper where
each map value was itself a Predicates struct with named per-source
fields. The JSON shape Go expected was
`{"deadline": {"deadline": {"status": [...]}}}`; the shape the bar
emitted was `{"deadline": {"status": [...]}}`. Go silently unmarshalled
the bar's payload as `Predicates{}` (all source fields nil), so every
chip click on /views/any was a server-side no-op — the regression in
#115.
The latent contract bug was present since t-paliad-144 A1 (b516201) but
only surfaced now: /inbox uses the InboxSystemView's code-resident
predicates (built in Go directly, doubled shape works) and saved views
never carried predicates in the DB, so chip-click overlays were the
only path that exercised the wire-format wrong way. /views/any made
that path visible because all four sources need narrowing.
Fix: align Go to the flat shape the frontend already emits.
- FilterSpec.Predicates: `map[DataSource]Predicates` → `*Predicates`.
- All `spec.Predicates[SourceX]` access sites in view_service.go +
approvalStatusMatches + allowed* helpers + system_views literals
+ tests rewritten to `spec.Predicates.X` with a nil-spec.Predicates
guard.
- Frontend FilterSpec.predicates type tightened from
`Partial<Record<DataSource, Predicates>>` (which silently allowed
the wrong runtime write) to `Predicates`.
Regression coverage:
- `filter_spec_predicates_test.go` (new, Go) pins three contracts:
the bar's exact wire payload unmarshals into a non-nil per-source
predicate; marshalling a Go-constructed spec produces the same flat
shape; the "Erledigt" chip's request narrows to completed deadlines.
- `compute-effective.test.ts` (new, bun:test) pins 12 chip-overlay
cases for /views/any (every axis the saved view's sources expose).
Build hygiene:
- `go build ./...` clean.
- `go test ./... -count 1` clean (existing inbox + filter_spec tests
updated for the new struct shape; new tests pass).
- `cd frontend && bun run build` clean.
- `cd frontend && bun test src/` — 169 pass, 0 fail.
No migration: paliad.user_views.filter_spec jsonb rows live with
`predicates: {}` or no predicates field; both unmarshal as nil
*Predicates under the new type, identical to the no-narrowing behaviour
the old map type produced for the same rows.
Adds .gitea/workflows/test.yaml that gates every push on `go build`,
`bun run build`, `go vet`, the migration coordination check, and the
role-split end-to-end migration smoke. On push to main + green, calls
Dokploy's compose.deploy API and polls /health/ready until 200.
t-paliad-282 / m/paliad#114. Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md
(inventor shift on mai/cronus/inventor-ci-cd-pre).
Catches all three of today's outage classes:
brunel (~13:20) slot collision -> TestMigrations_NoDuplicateSlot
hermes (~16:05) dropped-col refs -> TestBootSmoke
mig 129 (~14:56) 42501 ownership -> TestMigrations_EndToEndAsAppRole
Snapshot approach. internal/db/testdata/prod-snapshot.sql is a pg_dump
of youpc-supabase paliad schema + applied_migrations rows. CI restores
this into a fresh `supabase/postgres:15.8.1.060` (same image, same role
topology as prod) and runs ApplyMigrations as the `postgres` role
(which is NOT a superuser on supabase/postgres, matching prod). Existing
migrations are skipped (already in applied_migrations); only NEW migs
from the PR run end-to-end. This sidesteps the fresh-DB idempotence
debt in some historical migrations (mig 037 missing pg_trgm, mig 051
inner COMMIT) — those are tracked separately and don't block the gate.
Sub-changes:
- internal/handlers/handlers.go — new /health/ready endpoint distinct
from /healthz. /healthz stays liveness (process alive, no DB); /ready
is readiness (DB pool pings within 2 s). Returns 503 when svc or pool
is nil (DB-less deploys are intentionally not-ready). svc.Pool added
to handlers.Services, wired in cmd/server/main.go.
- internal/db/migrate_test.go — TestMigrations_NoDuplicateSlot (pure
unit, catches brunel) and TestMigrations_EndToEndAsAppRole (snapshot-
gated, catches the 42501 class).
- cmd/server/main_smoke_test.go — TestBootSmoke now also asserts
/health/ready returns 503 with a nil svc. New TestHealthReady_Live
asserts 200 against a live pool.
- internal/db/migrations/024_rename_department_columns.up.sql and
027_rename_to_partner_units.up.sql — ALTER INDEX / ALTER POLICY
exception handlers now catch undefined_object OR undefined_table OR
duplicate_object. Old handler only caught undefined_object; Postgres
raises undefined_table when source object never existed, and
duplicate_object when destination already exists. The expanded
handlers make these migrations truly idempotent across all plausible
starting states.
- Makefile — verify-mig-app, test-frontend, refresh-snapshot targets.
refresh-snapshot pg_dumps youpc-supabase prod (needs PALIAD_PROD_DATABASE_URL),
strips pg16 \restrict commands for pg15 restore compat, and filters
applied_migrations rows to this branch's max on-disk version.
- internal/db/testdata/README.md — explains the snapshot's purpose,
refresh procedure, and how to verify locally.
- docs/cicd-runner-setup-2026-05-25.md — one-time admin steps for
registering a Gitea Actions runner on mriver and wiring DOKPLOY_TOKEN
as a repo secret. Documents soft-launch plan per m's Q11.4 (keep
Dokploy's autoDeploy=true webhook alive for one week, disable after
the workflow has gated 5 successful deploys).
Build clean. Full go test ./internal/... ./cmd/... green without
TEST_DATABASE_URL. With TEST_DATABASE_URL + TEST_APP_DATABASE_URL set
to a supabase/postgres scratch + snapshot restored:
TestMigrations_NoDuplicateSlot, TestMigrations_EndToEndAsAppRole,
TestBootSmoke, TestHealthReady_Live all pass. Live-DB service tests in
internal/services/* fail under supabase/postgres 15.8 with a 42P08
parameter-binding error (unrelated to Slice A — tracked as a follow-up).
Add 12 Tier 1 procedural deadline rules from curie's audit §10
(docs/research-deadlines-completeness-2026-05-25.md), backfill the
UPC R.104/R.105 Interim Conference citation on upc.inf.cfi.interim
(m/paliad#116 / m's 2026-05-25 report), and fold in the audit Q6
cleanup of the 40 _archived_litigation.* rows.
New rules:
T1.1 upc.inf.cfi.cmo_review 15d / R.333.2
T1.2 upc.inf.cfi.confidentiality_response 14d / R.262.2 (trigger 25)
T1.3 upc.apl.order.grounds_orders 15d / R.224.2(b)
T1.4 upc.apl.order.response_orders 15d / R.235.2
T1.5 upc.inf.cfi.cons_orders 2mo / R.118.4
T1.6 upc.inf.cfi.rectification 1mo / R.353
T1.7 upc.pi.cfi.deficiency 14d / R.207.6(a)
T1.8 upc.pi.cfi.merits_start 31d OR 20wd (max) / R.213 + R.198.1
T1.9 upc.inf.cfi.translation_request 1mo BEFORE oral / R.109.1
T1.10 upc.inf.cfi.interpreter_cost 2wk BEFORE oral / R.109.4
T1.11 upc.inf.cfi.translations_lodge 2wk / R.109.5 (trigger 113)
T1.12 upc.pi.cfi.response UPDATE: re-anchor on .app, court-set
T1.8 uses Wave 2 Slice A primitives (mig 128: working_days unit +
combine_op='max'). T1.9/T1.10 use timing='before' with the
backward-snap path in deadline_calculator.go.
Also drops the deadline_rule_audit.rule_id FK constraint. The mig 079
audit trigger had a latent bug — it could not log DELETEs because the
FK rejected the post-delete INSERT (count(*) WHERE action='delete'
was 0 across the entire history). Audit tables are append-only
history and should not FK-constrain on live entity tables; before_json
preserves the full row state. Unblocking this also unblocks the §13b
Q6 cleanup.
Verified on Supabase: 13 rows present in post-fix shape, all
assertions in the DO-block pass, audit log now records 11 creates +
2 updates + 40 deletes for this migration.
Adds the post-submission court phase to upc.dmgs.cfi and the appeal
route to upc.pi.cfi. The Verfahrensablauf timeline currently stops at
the last party submission (dmgs.rejoin / pi.order); without these rows
the interim conference / oral hearing / decision / appeal sub-tree
never renders, even though atlas's #96 spawn mechanism is in place.
Migration 133 (single slot, coordinated with knuth's #116 on 132):
Section A — UPC Damages tree end (#117):
- upc.dmgs.cfi.interim court-set, R.105
- upc.dmgs.cfi.oral court-set, R.118 / R.250
- upc.dmgs.cfi.decision court-set, R.118 / R.144
- upc.dmgs.cfi.appeal_spawn 2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits
Section B — UPC PI appeal route (#118):
- upc.pi.cfi.appeal_spawn 2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits
PI orders under R.211 dispose of the urgent question and ride the
main 2-month track; the 15-day R.220.1(c) order track does not apply.
Same shape as mig 095 inf.appeal_spawn and the upc.inf.cfi
interim/oral/decision rows from mig 012. Court-set rows reuse the
shared interim-conference / oral-hearing / decision concepts.
Citations: docs/research-deadlines-completeness-2026-05-25.md §D + Tier 4 (R.144), docs/audit-upc-rop-deadlines-2026-05-08.md §D R.144 + §F R.220.1(a)/R.224.1(a). Per-row RoP citation in the migration header.
Idempotent INSERT NOT EXISTS guards per row + post-insert DO block that RAISEs EXCEPTION if any expected row is missing or the spawn shape (is_spawn / spawn_proceeding_type_id / parent_id) is wrong.
go build ./... clean, go test ./internal/... clean, bun run build clean.
Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).
Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
preserving every legacy draft's behaviour byte-for-byte.
Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
uses it when set; falls back to user.Lang otherwise — Slice 1's
format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
outside {de,en}. Project-scoped + global PATCH endpoints both
surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
predecessor. Returns the matched tier (per_code_lang / per_code /
skeleton_lang / skeleton / letterhead) so the editor knows whether
to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
alongside the DE one; per-code EN variants land in a parallel
submissionTemplateENRegistry (empty for now — EN templates land per
HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
`?language=de|en` query override (one-shot path, no draft row to
pull the column from); defaults to the user's UI lang.
Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
Switching the radio PATCHes `language` and the server returns the
freshly-resolved bag + preview HTML so the lawyer sees EN values
immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
sprachspezifische Vorlage)") shows when the resolved tier doesn't
match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.
Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.
Build hygiene: go build/vet/test clean; bun run build clean.
ProceedingType TS interface in admin-rules-list.ts and admin-rules-edit.ts
declared `name_de` but the Go ProceedingType model serialises `db:"name"`
as JSON key `name` (DE is the primary on the wire). Result: `pt.name_de`
was undefined for every row, so `${pt.code} · ${pt.name_de}` produced the
literal "upc.apl.cost · undefined" in the list (and the same in proceeding
selects of the edit page).
Frontend-only fix:
- Rename the field to `name` to match the API contract.
- Guard the label builder: if the active-language name is missing, fall
back to just the proceeding code rather than rendering "code · " (or
worse, the original "code · undefined" string).
Other admin pages that fetch /api/proceeding-types-db (deadlines-new,
deadlines-detail, project-form, fristenrechner) already read `pt.name`
correctly, so the bug was scoped to these two files. TriggerEvent's
`name_de` field is real and stays untouched.
Multi-select party picker on the dedicated submission draft editor —
lawyer picks which of the project's parties to mention in this
specific submission. Adds the t-paliad-277 variable-bag multi-party
shape ({{parties.claimants}}, {{parties.claimant.0.name}}) while
keeping the legacy flat aliases ({{parties.claimant.name}}) for every
existing .docx template authored before the rename.
Surfaces an explicit "Aus Projekt importieren" button + last-imported
timestamp at the top of the variable sidebar so the lawyer can re-pull
project-derived variables (project.*, parties.*, deadline.*,
procedural_event.*, rule.*) when the project data drifts away from the
saved draft overrides. firm.*, today.*, user.* overrides survive the
import — those values aren't sourced from the project record.
Schema: mig 131 adds two columns to paliad.submission_drafts:
- selected_parties uuid[] DEFAULT '{}'::uuid[]
Empty = include every party (legacy default).
Non-empty = restrict to the subset, grouped by role at substitution.
- last_imported_at timestamptz NULL
Bumped each "Aus Projekt importieren" click; surfaced in UI.
Backend:
- SubmissionVarsContext gains SelectedParties — filterPartiesBySelection
restricts the resolved bag before role bucketing.
- addPartyVars emits THREE coexisting forms per role: comma-joined
(parties.claimants), indexed (parties.claimant.0.name), and flat
legacy (parties.claimant.name → first selected claimant). Flat
aliases are kept forever per the issue's backward-compat contract.
- SubmissionDraftService.ImportFromProject strips overrides for
project-derived prefixes and bumps last_imported_at; rejects
project-less drafts (nothing to import from).
- New endpoint POST /api/submission-drafts/{id}/import-from-project.
- DraftPatch + PATCH handlers accept selected_parties.
- submissionDraftView now ships available_parties so the editor can
render the picker without an extra round-trip.
Frontend:
- submission-draft.tsx: new import-row + parties block in the sidebar.
- client/submission-draft.ts: paintImportRow / paintPartyPicker /
onPartySelectionChange / onImportFromProject; group parties by
role bucket (claimant / defendant / other) with DE+EN role-string
matching to mirror the backend bucketing.
- 3 new i18n keys (DE+EN): import.button, parties.title, parties.hint.
- CSS for the picker + import row in global.css.
Tests: 6 new unit tests in submission_vars_parties_test.go covering
the multi-party bag emission, German role-string bucketing, flat-alias
first-of-role resolution, empty-selection-means-all default, non-empty
restriction, and the isProjectDerivedKey policy that powers the
import path.
Build hygiene: go build/vet clean; go test -short ./internal/... pass;
bun run build clean (2876 i18n keys, scan clean).
Reorder Verfahrensablauf 'Browse a proceeding' so the user-input flow
matches the importance hierarchy: proceeding-type → side → appellant →
date / court / flags. Side was previously below the date input; it is
the most-defining input after proceeding-type, so it belongs above.
- frontend/src/verfahrensablauf.tsx: move .verfahrensablauf-perspective
block above .date-input-group inside step-2. Wrap the side radio
cluster in #side-radio-cluster and add a sibling #side-chip (hidden by
default) that the client swaps in when a project pre-fills the side.
Add a 1px divider between perspective and date-input groups. Update
step-2 heading from "Ausgangsdatum eingeben" → "Perspektive und Datum"
to honestly describe both controls now under the heading.
- frontend/src/client/verfahrensablauf.ts: read ?project=<id> on init,
fetch /api/projects/<id>, map our_side onto the side axis (mirrors
fristenrechner.ts ourSideToPerspective: claimant/applicant/appellant
→ claimant, defendant/respondent → defendant, else null) and render
the side row as a read-only chip + "Andere Seite wählen" override
link. The chip respects ?side= as an explicit user pick — URL wins
over project auto-fill, same precedence as fristenrechner. Override
swaps back to the radio cluster and drops ?project= from the URL.
Side-chip label is language-aware via onLangChange.
- frontend/src/styles/global.css: .verfahrensablauf-step2-divider
(1px hr between perspective and date blocks); .side-chip / -tag /
-value / -override styles mirror .proceeding-summary's chip look so
the two read as the same visual family.
- frontend/src/client/i18n.ts + i18n-keys.ts: 3 new keys
(deadlines.step2.perspective, deadlines.side.from_project,
deadlines.side.override) in DE + EN.
URL state stays backward-compatible: ?side= and ?appellant= survive
the reorder unchanged. Adding ?project= opts in to auto-fill; without
it the page behaves identically to before.
No backend / projection logic change.
The Akte-picker (Step 1) wraps its magnifying-glass icon + input in a
flexbox row (`.fristen-step1-search-row`) with `gap: 0.5rem`, expecting
the icon to participate in the flex layout. But the shared
`.fristen-search-icon` rule (used by the B2 search input) sets
`position: absolute; left: 0.875rem;` — and the step1-scoped override
only tweaked color + flex-shrink without resetting `position`.
Result: the icon was absolutely-positioned out of the flex flow and
overlapped the input text (since `.fristen-akte-search` has no
padding-left). Resetting `position: static` for the step1 context lets
flexbox + gap handle the spacing naturally — same pattern as
`.fristen-row-search-panel-input-wrap`, which already works.
Audited other search inputs with leading magnifying-glass icons:
- `.glossar-search` (Glossary, Courts, Links, Team, AdminTeam,
AdminEventTypes) — wrap `.glossar-search-wrap` is `position: relative`,
input has `padding: 0.65rem 4.5rem 0.65rem 2.5rem`. Fine.
- `.projects-search-input` (/projects index) — wrap is
`position: relative`, input has `padding: 0.5rem 0.75rem 0.5rem 2.4rem`.
Fine.
- `.fristen-search-input` (Fristenrechner B2) — wrap `.fristen-search-row`
is `position: relative`, input has
`padding: 0.75rem 2.5rem 0.75rem 2.6rem`. Fine.
- `.fristen-row-search-panel-input` (Fristenrechner row-search panel) —
pure flex layout with `gap`, icon non-positioned. Fine.
- `.sidebar-search-input` (global sidebar search) — pure flex layout.
Fine.
- Other search inputs (`event-search-input`, `event-type-search`,
`submissions-new-search`, submissions index) have no leading icon.
N/A.
m/paliad#96 — frontend wiring of the per-event-card choice flow on
both consumer surfaces.
Shared rendering core (verfahrensablauf-core.ts):
- CalculatedDeadline gains choicesOffered + appellantContext (mirror
the new server fields).
- deadlineCardHtml emits a ▾ caret next to the date when a rule
carries a non-empty choicesOffered, plus an inert chip span next to
the title that the popover module rehydrates after every render.
- bucketDeadlinesIntoColumns prefers appellantContext over the
page-level appellant for "both" rows when the per-card context is
set to claimant or defendant. "both" / "none" / "" all fall back to
the existing collapse logic. New test cases cover all three paths.
- CalcParams + calculateDeadlines pass projectId / perCardChoices
through to the backend.
New module (client/views/event-card-choices.ts):
- attachEventCardChoices wires a delegated click handler on the
result container; the caret opens a body-anchored popover with one
block per choice-kind the rule offers (appellant: 4 radio-style
buttons; include_ccr + skip: 2-way toggle).
- Active picks render as small chips on the card title; reseedChips()
repaints them after every renderResults() innerHTML rewrite.
- Skipped rows fade to 55% opacity via the timeline-item--skipped
class.
Page wiring:
- /tools/verfahrensablauf (unbound): commits mutate an in-memory list
+ the ?event_choices= URL param, then schedule a recalc. Shareable
via link, no persistence — same idiom as ?side= / ?appellant=.
- /tools/fristenrechner (project-bound): commits POST/DELETE to
/api/projects/{id}/event-choices. The next calculate() call sends
projectId so the server folds the persisted choices in.
i18n: 17 new keys under choices.* (DE primary + EN secondary). Caret
title, appellant/include_ccr/skip block titles + value labels, chip
labels, reset action, commit error toast.
CSS: caret, popover, options, chip parts, skipped-row fade.
Tests: 3 new bucketer cases covering AppellantContext propagation
(157 frontend tests pass).