Phase 3 Slice 5 Go-side: ErrInvalidProceedingTypeCategory typed
error + service-layer validation + handler-level mapping +
listing-side filter.
- services.ErrInvalidProceedingTypeCategory: typed error so
handlers can map to a 400 with a bilingual user-facing message
distinct from generic ErrInvalidInput.
- ProjectService.validateProceedingTypeCategory: looks up the
referenced proceeding_types.category and rejects with the typed
error if it's not 'fristenrechner'. Called from both Create and
Update before any DB write.
- DeadlineRuleService.ListProceedingTypesByCategory: extends the
existing ListProceedingTypes with an optional category filter.
Empty category passes through (legacy callers unaffected).
- GET /api/proceeding-types-db?category=<value>: handler reads the
query param and forwards it to the service. The project-create
/ project-edit pickers pass 'fristenrechner' so users never see
retired litigation codes.
- writeServiceError: maps ErrInvalidProceedingTypeCategory to
HTTP 400 with a bilingual message ("Verfahrenstyp muss ein
Fristenrechner-Typ sein / proceeding type must be a
Fristenrechner type"). Distinct from generic ErrInvalidInput so
the frontend can show a more helpful hint.
Defence-in-depth chain: frontend picker filter → service-layer
validation → DB trigger (mig 088). Each backstops the next.
Phase 3 Slice 5 Step F-2. BEFORE INSERT/UPDATE trigger on
paliad.projects rejects any write that binds proceeding_type_id to a
non-fristenrechner-category proceeding_types row. NULL is allowed.
PostgreSQL CHECK constraints can't reference other tables, so this
is the only way to evaluate the (proceeding_types.category =
'fristenrechner') predicate per row without restructuring the
existing FK relationship.
Trigger trades narrower FK + partial-unique-index approach for
keeping the existing schema reference (mig 027) untouched. Slice 9
or later may drop this trigger when the litigation category is
fully retired.
Error message is bilingual (German + English) so the Go handler can
either surface it verbatim OR — preferably — intercept the typed
service error first and emit a clean i18n string. mig 088 is
defence-in-depth; the Go service-layer validation is the primary
path.
Idempotent: CREATE OR REPLACE FUNCTION + DROP TRIGGER IF EXISTS
before CREATE TRIGGER.
Phase 3 Slice 5 Step F-1 (design §3.F, m's Q2 ruling). UPDATE any
paliad.projects row still pointing at a litigation-category code
to the fristenrechner-category equivalent:
INF → UPC_INF (UPC infringement, canonical reading)
REV → UPC_REV
APP → UPC_APP
CCR → NULL (no UPC_CCR — flag for legal review)
APM → NULL (no UPC_APM)
AMD → NULL (no UPC_AMD)
ZPO_CIVIL → NULL (no fristenrechner analogue)
Live-data reality: 11/11 projects carry proceeding_type_id IS NULL
today, so this migration touches zero production rows. Ships
defensively for any future test / staging / imported data.
NULL-remaps write a paliad.project_events row
('proceeding_type_remap_null') with the old code in metadata so a
legal-review pass can spot the project + pick a hand-mapped code.
Idempotency: WHERE pt_old.category = 'litigation' AND pt_old.code IN
(...). Re-running on a clean target is a no-op.
Hard assertion at end: zero non-fristenrechner-category references
remain post-mig. RAISE EXCEPTION on violation — fails the migration
loudly rather than relying on mig 088's runtime trigger to catch
the next write.
Audit-reason wrapper cites design §3.F so the rationale persists
forever (mig 079 trigger doesn't fire here directly — no
deadline_rules rows are touched — but set_config is harmless and
keeps the wrapper pattern uniform across all Phase 3 migrations).
Phase 3 Slice 4 test coverage. Adds:
- TestEvalConditionExpr (20 sub-cases): AND/OR/NOT compositions,
single-flag leaf, nested AND-of-OR-and-NOT, empty-args
vacuous-truth semantics, NULL-expr → legacy condition_flag
fallback (preserves the AND-of-flags behaviour for any
pre-Slice-2-style row), malformed JSON / unknown op / malformed
NOT all defensive-true (rule still renders).
- TestWireFlagsFromPriority (6 sub-cases): exhaustive enum +
safe-default for unknown values. Matches the reverse of the
Slice 2 mig 083 backfill mapping.
- TestApplyDuration_Matrix (7 sub-cases): 4 units × multiple
timings × calendar/holiday rollover. Includes the
Thu+1d-over-Tag-der-Arbeit edge that exercises the
weekend+holiday cascade.
Test file housekeeping:
- Drops TestIsCourtDeterminedRule (the function it tested no
longer exists; equivalence is preserved by mig 082's WHERE
predicate and verified by the Slice 2 backfill integrity test).
- Drops the unused models import that becomes orphaned.
- Renames the EventDeadlineService.applyDuration / addWorkingDays
method-receiver tests to call the package-level functions
directly. Same test names + expected dates; only the helper
signature shifted.
- Parity test still calls the same applyDuration body, now via
the unified helper.
Full test suite green locally (live DB tests skip when
TEST_DATABASE_URL is unset, as ever).
Phase 3 Slice 4 Step D (design §3.D, the last foundation slice).
Pure Go — no migrations. Collapses the proceeding-tree + Pipeline-C
calculators onto a single set of unified helpers + reads, all
without changing wire output.
Helpers (package-level in services/fristenrechner.go):
applyDuration(base, value, unit, timing, country, regime, holidays)
→ (raw, adjusted, didAdjust, reason)
Single source-of-truth for date arithmetic. Replaces:
- addDuration (proceeding-tree, no timing / working_days)
- applyDurationOnCalendar (Slice 3 Pipeline-C-only)
- EventDeadlineService.applyDuration / addWorkingDays methods
Handles: timing=before/after, units days/weeks/months/working_days,
weekend + holiday rollover for calendar units. working_days lands
on a working day by construction (no post-rollover).
evalConditionExpr(expr jsonb, conditionFlag []string, flags) bool
Long-form jsonb gate evaluator (design §2.4). Grammar:
leaf: {"flag":"X"}
AND: {"op":"and","args":[<n>...]}
OR: {"op":"or","args":[<n>...]}
NOT: {"op":"not","args":[<one>]}
NULL / empty / "null" → unconditional. Defensive fall-through
on malformed JSON / unknown ops (rule still renders — never
silently drop a deadline). Fallback to condition_flag
AND-semantics when expr is NULL but the legacy column is set
(defensive cover for any row Slice 2 missed).
wireFlagsFromPriority(priority) → (isMandatory, isOptional)
Derives the legacy wire pair from the unified priority enum:
mandatory → (T, F) — statutory must
optional → (T, T) — RoP.151 (opt-in, ☐ pre-unchecked)
recommended → (F, F) — situational filing
informational → (F, F) — never saves today
unknown → (T, F) — safe default
Slice 8 will swap the wire to emit priority directly.
Calculate (proceeding-tree) refactor:
- r.IsCourtSet column read direct, isCourtDeterminedRule() heuristic
function deleted. Slice 2 backfill (mig 082) wrote the column
using the exact heuristic predicate; column-read saves the
per-rule branch test at runtime.
- r.Priority drives the wire IsMandatory / IsOptional pair via
wireFlagsFromPriority. Read of r.IsMandatory / r.IsOptional
columns retained (compat-mode) but never decision-shaping.
- r.ConditionExpr drives the gate; condition_flag is the fallback.
- Added combine_op composite (max/min) branch for proceeding-tree
rules. No live Pipeline-A rules carry combine_op today (it's a
future-friendly column the rule editor will surface); the
branch is reachable but produces zero diffs on the current
corpus.
- timing=before + working_days now usable on proceeding-tree rules
via the unified applyDuration. No live Pipeline-A rules use them.
CalculateRule (single-rule card-click) refactor: same column reads
(IsCourtSet, ConditionExpr, Priority), unified applyDuration.
calculateByTriggerEvent (Pipeline C) refactor: switched to the
unified applyDuration; loses the redundant post-pick reason
recompute (applyDuration now returns reason directly).
EventDeadlineService.Calculate composite-note recompute now calls
the package-level applyDuration instead of the deleted method.
Frontend wire shape stays pixel-identical pre/post-Slice-4. The 17
condition_flag rules in the live corpus continue to gate via the
same (a) leaf or (b) AND-of-args evaluator branches mig 084
produced; jsonb path is exercised first, the array fallback
remains as defensive cover.
LOAD-BEARING regression guard for Phase 3 Slice 3. For every distinct
trigger_event_id in paliad.event_deadlines, calls Calculate (now
delegating through FristenrechnerService) AND independently re-runs
the legacy applyDuration math against the source row, asserting:
- count(returned deadlines) == count(active source rows for trigger)
- id, title, titleDE, durationValue, durationUnit, timing all match
- dueDate matches the independently-computed expected date (even
a 1-day diff fails the test — that's the entire point of the
read-only cutover window)
- isComposite matches (CombineOp != nil && alt_* set)
Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
Sweep guard: at least 77 rows must have been checked across all
triggers — if the test only walks 0 triggers (e.g. due to a SELECT
glitch), the final tally raises.
Trigger date is an arbitrary working day (2026-01-15) so weekend
rollover noise is minimal; the parity comparison is against an
inline expected value, not a fixed snapshot, so any date that
exercises the calculator works.
Phase 3 Slice 3 service-side rewire. EventDeadlineService.Calculate
now:
1. Looks up trigger event metadata (unchanged — the legacy response
shape still carries TriggerEvent + TriggerDate at the top level).
2. SELECTs source event_deadlines rows for the trigger to recover
(id, duration, alt_*, combine_op, notes_en) — the unified
UIResponse drops those fields. SELECT is still allowed by the
mig 086 read-only trigger; only writes are blocked.
3. Delegates the rule SELECT + math to FristenrechnerService.Calculate
with TriggerEventIDFilter set.
4. Merges the unified result with the source rows (join by Name =
title_de) to produce the legacy EventDeadlineResult shape with
ID, ruleCodes, isComposite, compositeNote intact.
5. Loads rule_codes from event_deadline_rule_codes (also still
readable) by source.id.
Public signature unchanged — /api/tools/event-deadlines callers see
no diff. The legacy applyDuration / addWorkingDays helpers stay on
EventDeadlineService for the pure-Go unit tests + the composite-note
leg-pick that the unified UIDeadline doesn't expose.
main.go wiring: NewEventDeadlineService gains the FristenrechnerService
dependency.
Phase 3 Slice 3 calculator-side rewire. Adds the Pipeline-C branch
to FristenrechnerService so the unified backend can serve
event-driven deadlines:
- CalcOptions.TriggerEventIDFilter *int64 — when non-nil, Calculate
dispatches to calculateByTriggerEvent (proceedingCode ignored).
- calculateByTriggerEvent — flat-rule calculator: SELECT rules
WHERE trigger_event_id = X, compute each via the new
applyDurationOnCalendar helper (handles timing='before',
working_days, combine_op alt-leg max/min). No parent_id chains,
no flag gating, no IsRootEvent / IsCourtSet semantics — those
are Pipeline-A concerns.
- applyDurationOnCalendar + addWorkingDays — package-level helpers
that the proceeding-tree calculator's existing addDuration
doesn't cover. Slice 4 will fold them into a single unified
helper when the proceeding-tree side also reads timing +
working_days from the unified rule shape.
- DeadlineRuleService.ListByTriggerEvent — SELECT rules scoped to
a single trigger_event_id, ORDER BY sequence_order (preserves
the 1000 + ed.id ordering mig 085 wrote). Skips
hydrateConceptDefaultEventTypes since Pipeline-C rules don't
carry concept_id today.
UIResponse for trigger-event calls returns empty ProceedingType /
ProceedingName — EventDeadlineService owns the trigger metadata in
the legacy CalculateResponse shape. That's a stable contract for
the caller and avoids polluting UIResponse with trigger-event-only
fields.
Phase 3 Slice 3 cutover-window guard. BEFORE INSERT/UPDATE/DELETE
trigger on paliad.event_deadlines raises EXCEPTION with a message
pointing the writer at paliad.deadline_rules. SELECT remains
unaffected.
Why: Slice 3 just moved 77 rows into the unified backend (mig 085).
Until Slice 4 cuts every reader over and Slice 9 drops the legacy
table, the two sides must not diverge. Letting any write through
event_deadlines would silently regress "Was kommt nach…" parity.
Supabase service_role bypasses RLS but NOT triggers — direct DB
maintenance (psql, migration scripts, MCP) is also blocked. That's
intentional: every further edit to event_deadlines pre-Slice-9 is a
mistake. Slice 9's mig ~090 will drop the table + this trigger
together as part of the legacy cleanup.
Function is plain (not SECURITY DEFINER): the trigger function only
RAISE EXCEPTIONs, no INSERTs anywhere, so it doesn't need elevated
privileges. Caller's RLS / role context doesn't matter — the raise
fires unconditionally before any tuple lock is taken.
Phase 3 Slice 3 Step C (design §3.C). INSERT 77 active rows from
paliad.event_deadlines into paliad.deadline_rules so the unified
backend can serve both pipelines. Source rows preserved (mig 086
wraps the source table in a read-only trigger; Slice 9 drops it).
Mapping:
trigger_event_id ← event_deadlines.trigger_event_id (bigint, mig 028)
name (DE, NOT NULL) ← event_deadlines.title_de (NOT NULL DEFAULT '')
name_en (NOT NULL) ← event_deadlines.title (EN, NOT NULL)
duration_value / unit ← event_deadlines.duration_value / unit
timing ← event_deadlines.timing (before / after)
alt_duration_value / unit ← event_deadlines.alt_duration_*
combine_op ← event_deadlines.combine_op (mig 078 column)
deadline_notes (DE) ← event_deadlines.notes (DE; NULLIF '' so empty
stays NULL on dr side)
deadline_notes_en ← event_deadlines.notes_en (mig 036)
legal_source ← event_deadlines.legal_source
published_at ← event_deadlines.created_at (chronological audit)
sequence_order = 1000 + ed.id (large offset so Pipeline-C rules
sort after any hand-authored
Pipeline-A sequence_orders; preserves
source ordering within Pipeline C)
lifecycle_state = 'published' / priority = 'mandatory' / is_active = ed.is_active
Pipeline-A-only fields stay NULL on the new rows: proceeding_type_id,
parent_id, spawn_proceeding_type_id, code, primary_party, event_type,
condition_expr, condition_flag. is_court_set = false (no court-set
rules in the Pipeline-C corpus today; legal-review pass can flip
Zustellung-* later via a separate slice).
Idempotency: WHERE NOT EXISTS guard on (trigger_event_id, name).
Re-running the migration is a no-op.
Hard assertion at end: COUNT(deadline_rules WHERE trigger_event_id
IS NOT NULL) must equal COUNT(event_deadlines WHERE is_active=true)
post-mig. RAISE EXCEPTION on mismatch — better to fail the migration
loudly than to ship a partial Pipeline-C corpus and poison Slice 4.
Audit-reason set via set_config so the mig 079 trigger writes 77
paliad.deadline_rule_audit rows with the design §3.C citation
preserved as the rationale. That's the persistent compliance trail
for the data-move.
No mandatory bool on event_deadlines (the head instruction sketch
suggested mapping it; the schema doesn't have one) — Pipeline-C
rules default priority='mandatory', consistent with the statutory
nature of the corpus.
Live-DB test (TEST_DATABASE_URL-gated, mirrors Slice 1 pattern)
validating mig 082/083/084 landed correctly:
1. is_court_set matches isCourtDeterminedRule() exactly. Counts
rows where is_court_set != (primary_party='court' OR
event_type IN ('hearing','decision','order')); must be zero.
2. priority is non-NULL everywhere (CHECK guards the schema —
this is belt-and-braces). Buckets by (is_mandatory,
is_optional) and asserts the design §2.3 mapping:
T/F → mandatory; T/T → optional; F/* → recommended.
3. condition_expr translation is complete + non-spurious:
- every non-empty condition_flag has non-NULL condition_expr
- every NULL/empty condition_flag has NULL condition_expr
- single-flag rows: condition_expr ->> 'flag' = condition_flag[1]
- multi-flag rows: condition_expr ->> 'op' = 'and' AND
jsonb_array_length(args) = array_length(condition_flag, 1)
The Slice 1 test's "every row priority='mandatory' && !is_court_set"
assertion is loosened to "priority in enum" + "lifecycle_state='published'"
since Slice 2 backfills now mutate those defaults.
Build clean, full test suite green (live DB tests skip locally).
Phase 3 Slice 2 Step B-3. Convert condition_flag text[] →
condition_expr jsonb per DESIGN §2.4 long form (NOT msg 1746's
short {"and":[...]} form — head clarified in msg 1750 that
design §2.4 wins because long form parses uniformly across
and/or/not, matching what the Slice-4 calculator + Slice-11 rule
editor will emit).
Mapping:
['with_ccr'] → {"flag":"with_ccr"} (5 rows)
['with_amend'] → {"flag":"with_amend"} (4 rows)
['with_cci'] → {"flag":"with_cci"} (4 rows)
['with_ccr', 'with_amend'] → {"op":"and","args":[
{"flag":"with_ccr"},
{"flag":"with_amend"}
]} (4 rows)
NULL or {} → NULL (155 rows)
Total translated: 17 rows.
Single-flag is unwrapped (no AND wrapper) per design §2.4 — a
shortcut equivalent to a 1-arg AND that saves a layer of nesting
without losing semantics. The calculator's parser treats
{"flag":"<name>"} as the leaf and {"op":"<and|or|not>","args":[…]}
as the canonical boolean node.
jsonb construction uses jsonb_build_object + a LATERAL unnest…WITH
ORDINALITY over the flag array so args[] order matches the source
array exactly (load-bearing if a future migration adds order-
sensitive ops).
Idempotent via WHERE condition_expr IS NULL — re-running doesn't
double-write audit rows for already-translated rules. Migration
ends with a DO block that RAISE EXCEPTION if any non-empty
condition_flag row still has NULL condition_expr (catches a
broken translation path before it reaches Slice 4).
Phase 3 Slice 2 Step B-2. UPDATE paliad.deadline_rules.priority
from the legacy (is_mandatory, is_optional) pair per DESIGN §2.3
(NOT msg 1746's inverted mapping — head clarified in msg 1750
that design §2.3 is the load-bearing spec).
Mapping:
T/F (153 rows) → 'mandatory' (statutory must, ☑ pre-checked)
T/T ( 1 row) → 'optional' (RoP.151 — opt-in deadline,
☐ pre-unchecked per mig 068)
F/T ( 0 rows) → 'recommended' (defensive; no live data)
F/F ( 18 rows) → 'recommended' (situational filings —
Berufungserwiderung, Replik,
Duplik, R.19 Preliminary
Objection, R.116 EPÜ, etc.)
Why NOT msg 1746's mapping:
- T/T → 'recommended' would PRE-CHECK RoP.151 in the save modal
and auto-create a Kostenentscheidung deadline the user didn't
ask for. That's the regression we'd ship.
- F/F → 'informational' would render 18 real filing deadlines
NEVER-SAVEABLE per design §2.3 ("informational … NEVER saves
as a deadline"). They'd disappear from save flows entirely.
T/F branch is intentionally skipped — mig 078 already defaults
priority='mandatory', so all 153 T/F rows are already correct.
Writing 153 needless audit rows would dilute the backfill trail.
Audit-reason cites design §2.3 — that's the persistent rationale
captured in paliad.deadline_rule_audit. Migration enforces NOT NULL
post-run via a DO block that RAISE EXCEPTION on stragglers.
Phase 3 Slice 2 Step B-1 (design §3.B). UPDATE paliad.deadline_rules
to set is_court_set=true where the live isCourtDeterminedRule()
heuristic returns true:
primary_party = 'court'
OR event_type IN ('hearing', 'decision', 'order')
Expected delta on the production corpus: 47 rows flipped false→true
(every primary_party='court' rule overlaps with a court event_type
in the current data, so the two predicates fully overlap at 47).
Replicates the live fristenrechner.go body EXACTLY, not the
ILIKE-padded sketch in msg 1746. Per head's ruling msg 1750:
padding with '%entscheidung%' / '%urteil%' would mis-flag party
filings like RoP.151 (Antrag auf Kostenentscheidung) and § 83 PatG
(Stellungnahme zum Hinweisbeschluss) as court-set. They aren't —
only their anchors are.
Audit footnote: ~8 'Zustellung…' rules (LG-Urteil, OLG-Urteil,
BPatG-Entscheidung, Beschwerdeentscheidung, DPMA-Entscheidung)
carry primary_party='both' + event_type='filing'. Semantically the
Zustellung date IS court-set; flagging them is left to the legal-
review pass mentioned in design §2.3, not this slice.
Idempotent via WHERE is_court_set = false. Audit-reason is set via
set_config('paliad.audit_reason', …, true) so the mig 079 trigger
captures one paliad.deadline_rule_audit row per flipped rule —
the persistent backfill trail.
Mig 081 was reserved for proceeding_types display_order verification
in design §3.1; it was a no-op and was not authored. Tracker
skips 081, advances 80 → 82. golang-migrate handles non-contiguous
numbers fine as long as the order ascends.
Phase 3 Slice 1 Go-side of mig 078–080. Compat-mode reads: the
service selects BOTH the legacy shape (is_mandatory, is_optional,
condition_flag, condition_rule_id) and the new shape (priority,
condition_expr, is_court_set, trigger_event_id,
spawn_proceeding_type_id, combine_op, lifecycle_state, draft_of,
published_at). Existing callers stay on the legacy fields until
Slice 4 cuts the calculator over.
Adds:
- DeadlineRule field block for the nine Phase 3 columns. NULLable
jsonb (condition_expr) uses NullableJSON to dodge the
json.RawMessage NULL-scan trap (see Project.Metadata note from
t-paliad-138 dogfood).
- Project.InstanceLevel *string.
- DeadlineRuleAudit row struct (id, rule_id, changed_by,
changed_at, action, before_json, after_json, reason,
migration_exported).
- ruleColumns const extended to project every new column.
Test (TEST_DATABASE_URL-gated, mirrors audit_service_test.go):
1. ruleColumns SELECT scans cleanly — every new column populates
its Go field.
2. Migration defaults land: priority='mandatory',
is_court_set=false, lifecycle_state='published' on every
pre-Slice-1 row.
3. Audit trigger writes one row on UPDATE WITH paliad.audit_reason
set, captures before+after JSON + reason.
4. Audit trigger RAISES on UPDATE WITHOUT paliad.audit_reason —
Slice 2 backfills fail loudly if they forget to set it.
5. paliad.projects.instance_level accepts NULL + first/appeal/
cassation, rejects 'final'.
Build clean, full test suite green (live DB test skipped locally).
Phase 3 Slice 1, design §2.7 + §7. Adds a nullable text column
gated by a CHECK to 'first' | 'appeal' | 'cassation'. Combined
with proceeding_code + jurisdiction, the FristenrechnerService
(Slice 8) will derive the effective proceeding code — e.g.
DE_INF + appeal → DE_INF_OLG.
No backfill in this slice. The project-detail picker UI (Slice 8)
writes the column; pre-Slice-1 rows stay NULL and behave as
implicit 'first' in the calculator's fallback.
Phase 3 Slice 1 audit-log foundation (design §2.8). The audit log
lands BEFORE the rule editor (Slice 11) so every future write to
paliad.deadline_rules is captured — including the Slice 2
backfill UPDATEs.
paliad.deadline_rule_audit columns mirror design §2.8 (changed_by,
changed_at, before_json / after_json, reason, migration_exported).
Two intentional deviations, documented inline:
1. changed_by is nullable, not NOT NULL. Trigger reads auth.uid()
which is NULL under service_role (migrations, server-side Go
using the service key). NOT NULL would block Slice 2 backfills
and every seed insert.
2. action values written by the trigger are 'create'|'update'|
'delete' (raw TG_OP). Go-authored audit rows additionally
write 'publish'|'archive'|'restore' (lifecycle_state flips
that the trigger sees as plain UPDATEs). The audit UI in
Slice 11 collapses the paired rows.
Trigger is SECURITY DEFINER so its INSERT into the audit table
bypasses the audit table's RLS — otherwise an authenticated
user's UPDATE on a rule would fail when the trigger tried to write
under their RLS context.
Audit-reason enforcement: trigger reads paliad.audit_reason via
current_setting(..., true) and raises EXCEPTION on UPDATE/DELETE
when unset. INSERT defaults to 'create' so seed migrations stay
ergonomic.
RLS: SELECT for global_admin only (mirrors mig 057 pattern). No
INSERT policy — the SECURITY DEFINER trigger and service_role are
the only writers.
Phase 3 Slice 1 Step A (design §3.1). Additive only; no drops, no
data change. Adds nine columns to paliad.deadline_rules so the
calculator + rule editor can converge on a single rule shape over
the following slices:
trigger_event_id (bigint, FK trigger_events.id)
spawn_proceeding_type_id (int, FK proceeding_types.id)
combine_op (text, CHECK 'max'|'min')
condition_expr (jsonb)
priority (text, DEFAULT 'mandatory', 4-way CHECK)
is_court_set (bool, DEFAULT false)
lifecycle_state (text, DEFAULT 'published', 3-way CHECK)
draft_of (uuid, self-FK)
published_at (timestamptz)
FK types follow the actual referenced columns (bigint on
trigger_events, int4 serial on proceeding_types) — the design doc's
"int FK" shorthand is widened to the precise widths.
FKs are DEFERRABLE INITIALLY IMMEDIATE so Slice 3's data-move can
defer FK checks within a single transaction without disturbing
normal-statement semantics.
Indexes: partial WHERE NOT NULL on the two FK columns (sparse;
most rules have neither); plain btree on lifecycle_state so the
admin filter on 'published' is O(log n).
Slice 4 step 2 (faraday-Q7). Wires shape="timeline" into the /views
shape switcher and the dispatch in client/views.ts.
New file shape-timeline-cv.ts holds the adapter:
- ViewRow.kind="deadline" → TimelineEvent kind="deadline" + deadline_id
- ViewRow.kind="appointment" → kind="appointment" + appointment_id
- ViewRow.kind="project_event" → kind="milestone" + project_event_id
- ViewRow.kind="approval_request" → SKIPPED (no chart-meaningful date)
- Lane axis = project_id (design §10 cross-project chart use case);
first-seen order keeps lanes deterministic across re-renders.
- Rows without project_id collapse to a synthetic "self" lane.
- Status comes from row.detail.status for deadlines (done/overdue),
defaults to "open" everywhere else.
shape-timeline-chart.ts gets a new ChartMountOpts.staticData escape
hatch: when supplied, mount() skips the /api/projects/{id}/timeline
fetch and paints from the supplied events + lanes directly. This is
what lets the CV adapter feed pre-loaded ViewRows into the same
renderer that powers /projects/{id}/chart — Slice 1-3 features
(palette, density, range chips, lane filter, permalink) all carry
over for free.
views.ts switches the active shape host and disposes the chart handle
on shape flips so resize listeners don't leak between mounts.
Tests (13 new): pin the kind mapping, lane bucketing by project_id,
status extraction precedence, date passthrough, empty-input safety.
Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5.
Slice 4 step 1 (faraday-Q7). RenderShape gets a fourth member
ShapeTimeline, AllShapes extends, Validate accepts it. The
companion TimelineConfig struct stores the saved palette / density /
range-preset for a CV-timeline view so re-opening the view restores
the same visual settings — same vocabulary as the standalone
/projects/{id}/chart URL state, just persisted in user_views.render_spec
instead of the URL.
Validator mirrors the frontend's enum guards:
- known palettes (default | kind-coded | track-coded | high-contrast | print)
- known densities (compact | standard | spacious)
- known range presets (1y | 2y | all | custom)
- ISO-date strings length-bounded to 32 chars so a hostile editor
can't bloat the jsonb column.
Tests pin every accept/reject path in TestRenderSpec_TimelineConfigValidates.
Design ref: docs/design-project-chart-2026-05-09.md §11.5 + §14 Q7.
Phase 1 audit (AUDIT ONLY, no implementation). 799 lines, mai/pauli/fristen-logic-audit.
Headline findings:
- THREE parallel deadline-generation systems coexist with overlapping
intent:
- Pipeline A (proceeding-driven) — paliad.deadline_rules (172 rows),
FristenrechnerService.Calculate, drives /tools/fristenrechner +
SmartTimeline.
- Pipeline B (single-rule subset of A) — Pathway B cascade click.
- Pipeline C (event-driven, youpc legacy) — paliad.trigger_events
(110) + paliad.event_deadlines (77), EventDeadlineService.Calculate,
drives "Was kommt nach…" tab. Disjoint corpus from A.
- Rule corpus is RICHER than the brief implied: 32 columns, 172 rules
across 27 proceeding_types (132 fristenrechner + 40 litigation). The
dual-corpus is a latent footgun: paliad.projects.proceeding_type_id
accepts both categories with no CHECK constraint, so a project's
SmartTimeline depends on which code lands first.
- Data model already encodes most of m's mental model:
multi-deadline triggers via parent_id chains (deepest live: 3
levels in UPC_INF), conditional via condition_flag (AND-only),
flag-swap via alt_duration_value / alt_rule_code, court-set via
heuristic + 4-bucket classification, holiday adjustment via
HolidayService+CourtService.
- Real gaps (§6, 13 of them):
- Pipeline A/C redundancy (different capabilities, disjoint data).
- Litigation vs fristenrechner corpus drift (no contract).
- is_mandatory + is_optional overlap.
- deadline_concept_event_types is config layer, NOT trigger model.
- No real event-driven trigger endpoint.
- AND-only condition_flag (no OR/NOT/compound).
- Cross-proceeding spawn half-wired.
- 9 orphan concepts with rule_count=0 (incl wiedereinsetzung,
schriftsatznachreichung, weiterbehandlung).
- condition_rule_id dead column.
- Instance dimension (LG/OLG/BGH) not on paliad.projects.
- 1/26 deadlines linked to rule_id (anchor-from-actuals barely
used).
- Court-set is heuristic, not first-class column.
- Pipeline A lacks before / working_days / combine_op.
- The big m's-question: "all in the Rules so we should be able to
manage" is FALSE today. Rules edits = SQL migrations only. §8
proposes a 3-step ladder: status-quo / read-only admin / full
editor with audit log.
- §7 has concrete extension proposal for each §6 gap (migration size
costed).
- §9 has 15 open questions for m to call before Phase 2 starts.
- Live data sparse: 11/11 projects NULL proceeding_type_id, 1/26
deadlines with rule_id — demand-side mostly empty even though
supply-side (rules) is rich.
NOT cronus per memory directive 2026-05-06. NOT self-merged. Awaiting
m's go/no-go.
Slice 3 step 5 (optional). The back-link on the chart page now points
explicitly at /projects/{id}/history (Verlauf sub-path) instead of
the bare /projects/{id}. Today's projects-detail.ts treats both the
same — bare and /history land on the Verlauf tab — but /history is
the explicit form, so the link keeps working if Verlauf ever stops
being the default tab.
Label flips from "Zurück zum Projekt" → "Zurück zum Verlauf" so
users see exactly where they're heading. Pairs naturally with the
Slice 1 "Als Chart anzeigen ↗" affordance: the trip is round.
Design ref: docs/design-project-chart-2026-05-09.md §8.1.
Slice 3 step 4 (head Slice-2 deferral). Implements head's option (a):
sidebar.ts walks the URL pathname on init and reveals a contextual
"Als Chart anzeigen" entry when it sits on a /projects/{uuid}/* page
that ISN'T already the chart itself.
Sidebar TSX gets a new hidden slot id="sidebar-project-chart-link"
right under the Übersicht group. The page never has to touch the
sidebar — initProjectContextChartLink owns the path-match and the
href population. Clean separation: pages don't know about the slot;
sidebar.ts doesn't know about pages.
UUID-shape regex prevents the chip from appearing on /projects (list)
or /projects/new. Rest-path check excludes /chart and /chart/ — the
chart page already has its own "Zurück zum Verlauf" path (Slice 1
link goes the other direction, a reciprocal can land in the next
commit).
i18n: 1 new key DE+EN under nav.context.project_chart.
Design ref: docs/design-project-chart-2026-05-09.md §8.1 +
Slice-2 head deferral resolution.
Slice 3 step 3 (faraday-Q10). The URL already aggregates every chip's
state via the individual writeParamToURL writers we built in Slice 2
and Slice 3 C1-C2 — palette + density + range + lanes. The copy
button just reads window.location.href and writes it to the clipboard.
Two-tier clipboard strategy:
1. navigator.clipboard.writeText in secure contexts (modern browsers,
localhost, paliad.de over TLS).
2. document.execCommand("copy") fallback for older / non-secure
contexts (file://, some iframes).
Visual feedback flashes green/amber on the button for 1.8s after the
click — no toast component needed, the button IS the affordance.
Permalink contract: reload an identical URL → visually identical
chart. Tested by hand on every chip combination; URL stays canonical
(default values omit their param) so shared links don't accumulate
defaults that drift if defaults change.
Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §14 Q10.
Slice 3 step 2. The chip group is rendered dynamically by the boot
client after refresh() reports lanes via the new onDataLoaded
callback — the lane labels and ids only exist after the server
responds, so static TSX can't render the chips. Hidden when the
projection has 0-1 lanes (filter has no value on a single-track
render).
setVisibleLanes(allowlist | null) on the chart handle filters BOTH
lanes and events in repaint() before passing to layout() — drops
unselected entirely (doesn't fall back to first-lane the way an
unknown stale id does). null = show all.
Stale lane ids are dropped from the URL-restored allowlist after
every refresh: deleted CCRs / child cases can't keep their lane id
alive across re-fetches.
URL state in ?lanes=id1,id2; absent / empty = show all. Hostile or
oversized ids are filtered (length cap 200) at parse time; the
allowlist intersection in repaint() defends again. Toggling every
chip back on collapses to null so the URL stays canonical.
Design ref: docs/design-project-chart-2026-05-09.md §3.2 + §8.2.
Slice 3 step 1. Four range presets per design §10 + faraday-Q8 default:
1y (today-1y..today+1y, default), 2y, all (derives bounds from loaded
events with a +30d right pad), and custom (date-pair inputs).
mount() grows currentRangePreset + customRangeFrom + customRangeTo so
the layout-time viewport is computed from the live preset, not the
constructor-time opts. resolveRange() handles the four cases; "all"
calls rangeFromEvents() over the last fetched timeline so completing
or adding a row reflows on next repaint.
URL state in ?range=1y|2y|all|custom (omit when 1y); custom adds
?from=&to=. ISO_DATE_RE guards malformed input. Custom date-pair
shows / hides based on the preset.
i18n: 7 new keys DE+EN under projects.chart.range.*.
Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §10 + §14 Q8.
Sidebar.tsx href flips from /tools/fristenrechner?path=a to
/tools/verfahrensablauf. The two Werkzeuge entries now resolve to
distinct pathnames, so the SSR navItem helper picks the right active
class on its own — fixVerfahrensablaufActive (which compared search
params client-side to disambiguate) is deleted along with its call
in initSidebar.
The new abstract-browse surface. TSX shell hosts:
- header (h1 + subtitle)
- jurisdiction-tabbed proceeding-tile picker (UPC / DE / EPA / DPMA)
- trigger date input
- court picker (visible only for proceedings with multiple
compatible courts — UPC_REV across CD + LD seats etc.)
- view toggle (Spalten / Zeitstrahl)
- result container
client/verfahrensablauf.ts wires picker click → calculateDeadlines →
renderColumnsBody/renderTimelineBody via the shared core. Pre-selects
the first proceeding tile on load so users see a timeline immediately,
matching /tools/fristenrechner's auto-render behaviour. No Akte
picker, no Pathway B cascade, no save modal, no anchor-override edit
— Slice 1 is the structural foundation; variant chips + lane view
(Slice 3) and compare (Slice 4) layer on top in later commits.
build.ts wires the new entrypoint + write step. i18n adds
tools.verfahrensablauf.title / .heading / .subtitle in DE + EN; the
existing nav.verfahrensablauf reused.
Backend half of Slice 1: a new dedicated route owns the abstract-browse
intent that was previously emulated by /tools/fristenrechner?path=a +
client-side fix-up. The page handler is a 1-liner that serves
dist/verfahrensablauf.html (no DB dependency).
A naked ?path=a on /tools/fristenrechner now 302s to the new URL so
bookmarked legacy links survive. ?project=<uuid>&path=a still serves
the fristenrechner shell because that's wizard state set by client-
side history.replaceState during Akte-mode Pathway A — refreshing
mid-wizard must not bounce away.
Test covers all four query shapes: naked path=a → redirect, path=a
with project → no redirect, no params → no redirect, path=b → no
redirect.
Server-side endpoint GET /api/projects/{id}/timeline.ics returns a
VCALENDAR + one VEVENT per actual deadline (VALUE=DATE all-day) and
appointment (UTC timestamp). Projected / milestone / off_script rows
are deliberately skipped — faraday-Q6 / m's pick: a calendar feed
must never carry predicted dates the user never confirmed, otherwise
Outlook fills with rule_code-derived events that erode trust.
FormatTimelineICS reuses the existing caldav_ical.go escape helpers
and writes through the same canonical UIDs (paliad-deadline-<id> +
paliad-appointment-<id>) so a re-subscribe updates entries instead
of duplicating them. Stable across re-exports = lawyer-safe.
Visibility piggybacks on ProjectionService.For + ProjectService.GetByID
(same gates as the chart page handler). Content-Disposition filename
slugged for portable ASCII so Outlook + Apple Calendar agree.
4 tests pin the contract: only deadline/appointment kinds emit
VEVENTs; undated rows skip cleanly; RFC 5545 §3.3.11 escaping for
; , \ \\n; empty input still produces a valid VCALENDAR.
i18n: 1 new key DE+EN.
Design ref: docs/design-project-chart-2026-05-09.md §7.8.
Five client-side export paths per design §7 (faraday-Q4: rule out
chromedp, browser-print is good enough).
- SVG: XMLSerializer over a clone of the live SVGSVGElement, with
--chart-* tokens inlined so the standalone file paints the same way
when opened in an image viewer (no document.css context).
- PNG: SVG → Image → Canvas at 2× DPR, toBlob("image/png"). White
background painted first so transparent SVG stays printable.
- PDF: window.print() → @media print stylesheet hides chrome, forces
the print palette tokens, locks A4 landscape via @page. User picks
"Save as PDF" in the browser print dialog. No chromedp dep.
- CSV: 20-column flat schema mirroring TimelineEvent, UTF-8 BOM for
Excel-DE, RFC 4180 escaping.
- JSON: events + lanes envelope + export-metadata header (project_id,
project_title, exported_at).
Export menu uses native <details>/<summary> so it's keyboard-accessible
without JS. The chart handle exposes getSVGElement() + getData() so
chart-export.ts stays pure: it never reads DOM state outside the SVG
it's handed.
Filenames are sanitised + dated: paliad-{title}-{yyyy-mm-dd}.{ext}.
i18n: 7 new keys DE+EN under projects.chart.export.*.
Design ref: docs/design-project-chart-2026-05-09.md §7.
Density flips lane height (24/40/64) and mark radius (5/7/10) via the
existing LANE_HEIGHT / MARK_RADIUS tables in shape-timeline-chart.ts.
Unlike palette (pure CSS swap), density needs a repaint because it
changes layout() output — setDensity() on the handle re-runs the
layout pure function with the new viewport.density.
URL state in ?density=<compact|standard|spacious>, default omitted.
The writeParamToURL helper is now shared between palette + density to
keep the canonical URL short (omit when value equals the default).
i18n: 4 new keys DE+EN under projects.chart.density.*.
Design ref: docs/design-project-chart-2026-05-09.md §6.1.
Slice 2 ships all 5 palettes from design §5.1 (m's pick on faraday-Q5):
default / kind-coded / track-coded / high-contrast / print.
Each palette is a pure data-attribute swap of the --chart-* tokens on
.smart-timeline-chart[data-palette="..."]. The renderer never reads
palette state — it stamps classed SVG nodes and the tokens flow in
via CSS variable cascade. setPalette() on the chart handle is a
one-line attribute write; no repaint.
URL state lives in ?palette=<name>; default omits the param so the
canonical URL stays clean. Initial paint reads the URL, every change
writes via history.replaceState — bookmarkable per design §8.2.
Unknown values silently fall back to default (defence against stale /
hostile URLs).
i18n: 6 new keys DE+EN under projects.chart.palette.*.
Design ref: docs/design-project-chart-2026-05-09.md §5 + §8.2.
Slice 1 served dist/projects-chart.html unconditionally, leaking a 200
for any well-formed UUID guesser. Slice 2 resolves the project via
ProjectService.GetByID before serving — ErrNotVisible (and any other
visibility error) collapses to 404 + the standard notfound chrome,
matching the JSON-API contract that already lives in writeServiceError.
A genuine DB error logs through writeServiceError's existing path but
still renders 404 chrome to the user (httpDevNullJSON wrapper discards
the JSON body writeServiceError would otherwise emit, keeping the log
side-effect intact).
Test pins serveChartNotFound: 404 + non-empty body, degrading
gracefully when dist/notfound.html is absent (test env).
Closes Slice 1 edge case #2 flagged at m/paliad#35 issuecomment-7710.
Design ref: docs/design-project-chart-2026-05-09.md §8.2.