Wires the project context into the Determinator row stack so a UPC INF
matter doesn't need to be hand-walked through five obvious cascade picks.
Auto-walk descends single-option chains as `is-prefilled` rows, the inbox
row vanishes for UPC matters (CMS implied), and the first prefilled row
carries the project reference inline ("aus Akte: HL-2024-001").
Backend: `internal/services/proceeding_mapping.go` adds
MapLitigationToFristenrechner — single source of truth for bridging the
litigation conceptual codes (INF / REV / APP / CCR / AMD / APM / OPP) onto
fristenrechner codes (UPC_INF / DE_INF / EPA_OPP / …). Ambiguous combos
(APP+DE, ZPO_CIVIL, AMD+DE) return ok=false; callers degrade to "no
narrowing" instead of guessing. Table-driven test covers every documented
mapping plus the ambiguous-degrade cases.
Frontend: `buildRowStack` filters cascade children by project context
along the proceeding axis (kebab segment lookup against the project's
fristenrechner code); auto-walks while filtered scope narrows to one;
caps depth via `cascadeAutoWalkStopAfter` after an "ändern" on a prefilled
row so the user lands at an active chip set without the auto-walk
re-engaging. Result panel narrows on the post-auto-walk effective slug,
not the URL slug. A one-time inline tooltip ("Diese Schritte ergeben sich
aus Ihrer Akte") surfaces when ≥2 rows render prefilled — dismissal flag
persists in localStorage.
Narrowing is purely additive: an Akte without a fristenrechner code
(11/11 live projects pre-Slice-5 were NULL) degrades to today's
forum-only behaviour. Slice 3 (mobile polish + search relocation) follows.
Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 2 + §4 + §5.
Phase 3 Slice 9 test cleanup. Seeds + assertions no longer touch
the legacy columns (mig 091 dropped them).
- projection_service_test.go (Slice 7 fixtures): INSERT seeds
drop the is_mandatory / is_optional columns from the
paliad.deadline_rules column list. Defaults are fine; the
spawn-graph test doesn't read those.
- rule_editor_service_test.go (Slice 11a fixtures): same drop
on the SLICE11A_PREVIEW seed.
- fristenrechner_test.go (Slice 8 wire-shape assertion): drops
the wireFlagsFromPriority round-trip check (the bool pair is
no longer on the wire). The enum-membership invariant
survives. evalConditionExpr table-driven test rewritten —
legacy condition_flag fallback cases removed (the fallback
is gone in Slice 9), pure-jsonb cases retained.
- deadline_rule_service_test.go (Slice 2 backfill integrity):
legacy-pair bucket assertion dropped; the priority-non-NULL
invariant still holds via the CHECK constraint. The
condition_flag cross-check now joins the pre-mig-091 snapshot
when present (a future cleanup slice drops the snapshot
along with this code path).
Build + tests green.
Phase 3 Slice 9 Go cleanup. With mig 091's column drops live, the
service layer stops reading + emitting the legacy shape:
- models.DeadlineRule: drop IsMandatory, IsOptional, ConditionFlag,
ConditionRuleID fields. Comment block flags Slice 9 as the
closeout slice.
- DeadlineRuleService.ruleColumns: SELECT no longer enumerates the
dropped columns. The post-Slice-9 schema is the live shape.
- FristenrechnerService.UIDeadline: drops IsMandatory + IsOptional
fields. Frontend reads `priority` directly post-Slice-8; the
legacy emit was kept "for one release" and that release is now.
- evalConditionExpr signature: drops the conditionFlag fallback
param. NULL / "null" expressions return true (unconditional);
the legacy text[] fallback was the only reason for the second
param. New helpers hasConditionExpr + extractFlagsFromExpr fill
the gaps (alt-swap guard + RuleCalculation.FlagsRequired list).
- FristenrechnerService.Calculate + calculateByTriggerEvent +
EventTriggerService.Trigger: switched to the new (single-arg)
evalConditionExpr; alt-swap guard now uses
hasConditionExpr(r.ConditionExpr) instead of the dropped
len(r.ConditionFlag) > 0 check.
- FristenrechnerService.CalculateRule: RuleCalculationRule.IsMandatory
derived from priority via wireFlagsFromPriority (kept for the
result-card panel TS contract). FlagsRequired walks the jsonb
gate tree to enumerate {"flag":"X"} leaves (replaces the
dropped condition_flag enumeration).
- RuleEditorService.Create + CloneAsDraft INSERT statements:
dropped is_mandatory / is_optional / condition_flag from the
column lists. Live shape only.
Test fixtures (projection_service_test.go, rule_editor_service_test.go,
fristenrechner_test.go) all updated to write the live shape on
seed; the evalConditionExpr table-driven test dropped its legacy
fallback cases (the fallback no longer exists) and now exercises
20 pure-jsonb scenarios across AND/OR/NOT compositions.
The deadline_rule_service_test backfill assertion lost its
(is_mandatory, is_optional) bucket cross-check (those columns are
gone); the priority-non-NULL invariant still holds via the CHECK
constraint. condition_flag cross-check now joins the pre-mig-091
snapshot table (when present) instead of the live row.
Phase 3 Slice 9 Step E (design §3.E, §9.1). m approved the
downtime window 2026-05-15 ("paliad ist nicht in use heute,
downtime ist egal") so the destructive drops can land.
Drops four superseded columns on paliad.deadline_rules:
is_mandatory → priority='mandatory' | other (Slice 2 mig 083)
is_optional → priority='optional' (Slice 2 mig 083)
condition_flag → condition_expr (Slice 2 mig 084)
condition_rule_id → DEAD (no live rows, Q13 m's approved drop)
Pre-drop snapshot: paliad.deadline_rules_pre_091 (id +
the four columns + snapshotted_at). Lets the down-migration
restore values to existing rows; a follow-up cleanup slice drops
the snapshot table once the rule editor's migration-export flow
has been used to roll any post-drop edits back into version
control.
Hard assertions at end:
- count(priority IS NULL) == 0 (Slice 2 mig 083 must have run).
- count(rule with pre-drop condition_flag but no condition_expr)
== 0 (Slice 2 mig 084 must have populated every row).
Both raise EXCEPTION on violation — fails the migration loudly
before legacy code paths get pulled out from under the unified
calculator.
Audit-reason wrapper set; ALTER TABLE DROP COLUMN doesn't fire
the mig 079 row-level trigger, but the wrapper is the standard
Phase 3 pattern.
Sibling drops deferred — see live-data audit in head ping:
- mig 092 (event_deadlines + trigger_events tables): SKIPPED.
trigger_events has 33 event_types FKs + 77 deadline_rules
FKs; event_deadlines + event_deadline_rule_codes still
consumed by EventDeadlineService.Calculate for the frontend's
"Was kommt nach…" tab (/api/tools/event-deadlines is still
in use post-Slice-3 delegate).
- mig 093 (retire litigation category): SKIPPED. 40 active
deadline_rules still reference litigation-category
proceeding_types (the Pipeline-A INF/REV/CCR/APM/APP/AMD/
ZPO_CIVIL rules; Slice 5 retired them from project-binding,
not from the rule corpus).
Both deferrals are tracked in the head ping; the litigation drop
can land after a focused slice that splits the Pipeline-A rules
off the litigation category onto a fristenrechner-side parent.
The event_deadlines drop needs EventDeadlineService.Calculate
to stop reading the source rows first.
Adds the Phase B paliad-side migration: a thin HTTP client of the
centralized aichat backend shipped in m/mAi#207 Phase A (darwin's
mai/darwin/issue-207-aichat branch). Implements the same services.Paliadin
interface as LocalPaliadinService / RemotePaliadinService — handler
plumbing is unchanged, the cutover is a single env-var flip.
internal/services/aichat_paliadin.go (~530 LoC):
- POST /chat/turn + POST /chat/reset + GET /chat/health via the aichat
JSON envelope (mirrors m/mAi internal/aichat/api/types.go verbatim;
no module import to keep paliad self-contained).
- Per-turn HS256 JWT mint (uses paliadin_jwt.go from the prior commit)
when SUPABASE_JWT_SECRET is configured. Aichat owns file write +
cleanup; we just sign and ship.
- Service-wide health-gate cache (10 s success window, no failure
cache — failures re-probe so recovery surfaces immediately).
- Per-user-window primer cache. Pulls up to MaxPrimerTurns prior
exchanges from paliad.paliadin_turns and ships them in TurnRequest.
Primer so a pane respawn (pane_spawned=true in response) doesn't
strand the user with a cold claude. Cleared on ResetSession +
pane_spawned response.
- Username from email_localpart per m's §13 Q2 pick (sanitized inside
aichat). Nil-DB fallback: "user-<uuid8>".
- Maps aichat's typed wire errors (auth_failed, persona_unknown,
mriver_unreachable, bootstrap_failed, timeout, shim_error) onto
paliad's existing audit-row codes — preserves the German i18n table
in paliadin.ts unchanged (no new strings needed per design §11).
cmd/server/main.go:
- PALIADIN_BACKEND env: "aichat" → AichatPaliadinService, anything
else → existing remote/local/disabled tree. Default = legacy, so
every existing deploy is byte-identical until flipped.
- buildAichatPaliadinConfig validates AICHAT_URL + AICHAT_TOKEN at
boot; AICHAT_PERSONA defaults to "paliadin". JWT secret threaded
in so per-user RLS is on by default.
Tests cover constructor defaults, health-gate caching + retry +
expiry, ResetSession wiring, error-envelope decoding + classifier,
HTTP-layer auth/JSON wiring via a roundTripper, JWT mint integration,
TurnContext → meta packing, and the env-gate helper. go test ./...
green. NOT self-merged — head owns the merge per task instructions.
Restored from mai/planck/paliadin-per-user-rls (parked, see m/paliad#12
cancel note). The aichat Phase B path (next commit) consumes mintTurnJWT
to sign a short-lived HS256 token per turn, scoped to the calling user
(sub=userID, role=authenticated, aud=authenticated, iss=paliad/paliadin).
Aichat passes the raw token through to the claude pane on mRiver via a
per-turn file (managed by aichat's runner, not paliad's transport). The
SKILL.md reads it and `SET LOCAL request.jwt.claims = …` before every
paliad.* query, which makes RLS evaluate as the user instead of as
service role.
TTL: 2 min default — covers aichat's 120 s persona timeout + HTTP slack,
short enough that a leaked JWT is uninteresting. Each turn mints fresh;
no caching.
No call sites yet — paliadin_remote.go / paliadin.go are unchanged on
this commit. The plumbing arrives with AichatPaliadinService.
Slice 11b backend addition for the orphan-resolution flow in the
/admin/rules UI. The Slice 10 fuzzy-match backfill (mig 089) staged
legacy paliad.deadlines rows the matcher could not bind to a unique
deadline_rule into paliad.deadline_rule_backfill_orphans. This adds
the two endpoints the editor needs to surface and resolve them:
GET /admin/api/orphans — unresolved staging rows,
hydrated with the candidate
rule rows in one round-trip.
POST /admin/api/orphans/{id}/resolve — picks a rule_id from the
candidate set, writes it onto
the deadline, and flips
resolved_at + resolved_rule_id
on the staging row in a single
tx.
The methods live on RuleEditorService because they share the same admin
surface and audit semantics; resolved_rule_id + resolved_at on the
staging row is the audit trail (mig 089 COMMENT). reason is captured
into paliad.audit_reason in the same tx so any future audit trigger on
paliad.deadlines picks it up automatically.
Typed errors:
ErrOrphanAlreadyResolved → 409 in handler
ErrOrphanCandidateMismatch → 400 in handler
Route ordering matches Slice 11a's pattern: the static path is
registered alongside the existing /admin/api/rules family inside the
adminGate block in handlers.go.
Live-DB tests (TEST_DATABASE_URL-gated) for Phase 3 Slice 11a:
TestRuleEditorService_Lifecycle — full create→update→publish→archive
→restore round-trip on synthetic fixtures (SLICE11A_TEST_PT
proceeding + rules). Asserts:
1. Create returns lifecycle_state='draft' with published_at=NULL.
2. UpdateDraft on a draft succeeds and lands the patch.
3. CloneAsDraft from a published row creates a new draft with
draft_of pointing at the source.
4. Publish flips draft → published, sets published_at, AND archives
the cloned-from peer (verified by re-reading the peer's
lifecycle_state post-publish).
5. Archive flips published → archived.
6. Restore flips archived → published.
7. ListAudit returns ≥ 3 rows newest-first with non-empty reason
strings (the mig 079 trigger captured them).
8. Empty audit_reason on UpdateDraft → ErrAuditReasonRequired.
9. UpdateDraft on a published row → ErrInvalidLifecycleState.
10. Restore on a non-archived row → ErrInvalidLifecycleState.
TestRuleEditorService_Preview — calculator override hook coverage
(SLICE11A_PREVIEW_PT proceeding + a published rule). Clone the
root rule, patch DurationValue 30 → 60 on the draft, call Preview
at trigger_date=2026-01-15. Asserts:
- Baseline Calculate (no overrides) returns the published rule's
dueDate (~30 days after trigger).
- Preview returns a DIFFERENT dueDate (substitutes the draft's
60-day duration via RuleOverrides) — sanity check that the
override pipeline reached the calculator and shifted the date.
- Both responses are non-empty (the rule is reachable).
Cleanup: WHERE name LIKE 'SLICE11A_TEST_%' / 'SLICE11A_PREVIEW_%'
AND code = 'SLICE11A_TEST_PT' / 'SLICE11A_PREVIEW_PT' so production
rules are untouched. audit_reason set on every seed / cleanup write
so the mig 079 trigger doesn't reject the seed transactions.
Build clean, full local test suite green; this test skips when
TEST_DATABASE_URL is unset.
Phase 3 Slice 11a admin endpoints under /admin/api/rules, all
gated through auth.RequireAdminFunc:
GET /admin/api/rules — paginated list with filters
GET /admin/api/rules/{id} — full row
POST /admin/api/rules — create draft
PATCH /admin/api/rules/{id} — update draft only
POST /admin/api/rules/{id}/clone-as-draft
POST /admin/api/rules/{id}/publish
POST /admin/api/rules/{id}/archive
POST /admin/api/rules/{id}/restore
GET /admin/api/rules/{id}/audit — paginated audit log
GET /admin/api/rules/{id}/preview — preview-on-trigger-date
GET /admin/api/rules/export-migrations — SQL blob for the
migration-export flow
Every write endpoint takes a `reason` body field; missing reason →
HTTP 400 (ErrAuditReasonRequired surfaced by the service). The
service writes the reason into paliad.audit_reason in the same tx
as the UPDATE so mig 079's trigger captures it.
writeRuleEditorError maps service-level typed errors to HTTP
statuses (404 for ErrRuleNotFound, 409 for ErrInvalidLifecycleState
+ ErrCyclicSpawn, 400 for ErrAuditReasonRequired + ErrInvalidInput).
dbServices gains a ruleEditor field; Services.RuleEditor in the
public bundle gets wired from main.go via NewRuleEditorService.
Route ordering: export-migrations is registered BEFORE the
{id}-shaped routes so the static path doesn't get captured by the
{id} placeholder. (Go 1.22+'s ServeMux requires the explicit
registration order for shadowing-resolution.)
Frontend (Slice 11b) will hire a new coder to surface the API in
an admin UI. Slice 11a ships the backend in isolation so the editor
can drive the lifecycle via curl / mai instructions today.
Phase 3 Slice 11a (m's Q5 option C: "I need to see these things,
admin only"). RuleEditorService owns the admin-only lifecycle for
paliad.deadline_rules:
Create → INSERT row with lifecycle_state='draft', published_at=NULL.
UpdateDraft → UPDATE WHERE id=$1 AND lifecycle_state='draft'.
Published or archived rows must clone-as-draft first
(ErrInvalidLifecycleState otherwise — 409).
CloneAsDraft → INSERT deep copy of source row (published OR archived)
as a new draft with draft_of pointing at the source.
Lets editors propose changes to live rules without
mutating the live row.
Publish → UPDATE lifecycle_state='published', set published_at.
When draft_of != NULL, also archives the cloned-from
peer so each rule has at most one live row.
Archive → UPDATE lifecycle_state='archived' (allowed from
published OR draft).
Restore → UPDATE lifecycle_state='published' (only from archived).
Preview → Calls FristenrechnerService.Calculate with the draft
as a RuleOverrides entry — pure simulation, no DB
write. If draft_of is set, the override substitutes
for the peer (matching ID); otherwise it's appended.
ListAudit → SELECT paliad.deadline_rule_audit rows for one rule,
newest-first, with offset/limit pagination. Joined
with paliad.users.display_name for the changed_by
column.
ListRules → Admin list view with filters (proceeding_type_id,
trigger_event_id, lifecycle_state, fuzzy q over
name / name_en / rule_code).
ExportMigrationsSince → SQL blob generator for the migration-export
admin flow (Q-H-5 pure SQL format). v1 emits one
statement per audit row in chronological order;
Slice 11b polishes the output (header comment,
collapse consecutive UPDATEs).
Audit-reason invariant: every write method requires a non-empty
reason string. setAuditReasonTx writes it into the session-local
paliad.audit_reason setting in the same transaction as the
INSERT/UPDATE, so mig 079's trigger captures the rationale
forever. Empty reason → ErrAuditReasonRequired (400 in the handler).
Spawn cycle guard: validateSpawnNoCycle pre-checks Create + UpdateDraft
edits that touch spawn_proceeding_type_id against the global rule
graph. Reuses the design §6 cycle-guard semantics — walks the
target proceeding's spawn rules transitively; raises ErrCyclicSpawn
if any reachable proceeding is the source. Slice 7's runtime guard
catches anything this misses; the editor surface catches it at
edit time so the editor sees a clear 409 instead of a silent
projection failure.
Typed errors:
ErrRuleNotFound → 404 in handler
ErrInvalidLifecycleState → 409 in handler
ErrAuditReasonRequired → 400 in handler
ErrInvalidInput → 400 (re-uses the existing services-wide error)
ErrCyclicSpawn → 409 (re-uses Slice 7's typed error)
RuleAuditEntry struct extends models.DeadlineRuleAudit with a
display_name for the admin UI; distinct from services.AuditEntry
(the cross-source union for the site-wide audit panel) so the two
read paths don't conflict.
Phase 3 Slice 11a calculator hook for the rule-editor preview
(design §4.5, Q-H-4 option (a)). CalcOptions gains RuleOverrides
[]models.DeadlineRule. When non-empty, FristenrechnerService.Calculate
substitutes any rule with matching .ID in the rule list with the
override row, and appends overrides whose ID doesn't match an
existing rule (net-new drafts the editor wants to preview).
Wired into:
- FristenrechnerService.Calculate (proceeding-tree path)
- FristenrechnerService.calculateByTriggerEvent (Pipeline-C path)
Helper: applyRuleOverrides(src, overrides) — small linear scan since
the override slice is 1 row in practice (the draft being previewed).
Empty overrides → pass-through (existing behaviour unchanged).
No DB writes; pure simulation. The editor's "what would this rule
do?" affordance uses this to preview the draft against the rest of
the proceeding's rules without mutating the live corpus.
Phase 3 Slice 10 Step I (design §3.I + m's Q10 ruling). Binds legacy
paliad.deadlines.rule_id to deadline_rules.id via priority-ordered
fuzzy matching; ambiguous + no-match rows log to the orphan staging
table (mig 089).
Matching strategies (highest priority first; first unique hit wins):
1. rule_code_and_tail — title's leading citation token AND its
post-separator name fragment match a rule. Handles
"RoP.023 — Klageerwiderung" where the bare code matches 2 rules
(DE Klageerwiderung + EN Statement of Defence); the tail picks
the right one.
2. rule_code only — bare rule_code from the title prefix. Handles
"RoP.029.a — Replik" where RoP.029.a maps to a single rule
regardless of suffix (the title's "Replik" doesn't match the
rule's actual name but the code is unique).
3. name_exact — full title equals rule.name or rule.name_en
(LOWER). Catches "Antrag auf Schadensbemessung" (1 unique
rule); ambiguous for shared names like Klageerwiderung (8
candidates).
4. concept_alias — title appears in deadline_concepts.aliases.
Thin coverage today; Slice 12 orphan-seed will populate it.
Per-deadline aggregation:
- Strategy with n_candidates = 1 wins. Priority chain rule_code_and_tail
> rule_code > name_exact > concept_alias.
- Ambiguous (≥2 across all strategies) → orphan reason='ambiguous'
with the full candidate_rule_ids list.
- 0 candidates → orphan reason='no_match'.
Predicted production outcome (verified via supabase MCP pre-write):
- 3 of 25 deadlines (12%) get a unique match:
"RoP.023 — Klageerwiderung" via rule_code_and_tail
"RoP.029.a — Replik" via rule_code
"Antrag auf Schadensbemessung" via name_exact
- 15 of 25 deadlines (60%) → orphan reason='ambiguous' (common
titles like Klageerwiderung × 4, Duplik × 4, Replik × 4 across
multiple proceedings).
- 7 of 25 deadlines (28%) → orphan reason='no_match' (free-text
titles like "Call me", "Schutzschrift", "Validierungsfrist EP→DE",
"Schriftsatz nach R.262 (Klageerwiderung)").
The 60% target the design § hinted at is unachievable on today's
corpus because all 11 projects have proceeding_type_id IS NULL post-
Slice-5 (the fristenrechner-side rebinding hasn't happened on
production data yet) — proceeding-narrowing would cut the
Klageerwiderung / Duplik / Replik ambiguity, but the column isn't
populated. The orphan-review UI in Slice 11 is the real path to
binding the long tail.
Defensive backup: paliad.deadlines_pre_089 snapshot taken before any
UPDATE. Down-migration restores rule_id from the snapshot + drops
unresolved orphan rows (resolved rows survive a rollback — those are
legal-review work that shouldn't disappear on a code revert).
Idempotency: WHERE rule_id IS NULL on the UPDATE; orphan INSERT
skips rows that already have an unresolved orphan entry. Re-running
on the same corpus produces no new rows.
Hard assertion: every NULL-rule_id deadline (with project) is either
resolved post-mig OR has an unresolved orphan row. RAISE EXCEPTION on
any unaccounted row — fails the migration loudly rather than
silently leaving a deadline un-matched + un-orphaned.
Audit-reason wrapper set; the mig 079 deadline_rules audit trigger
doesn't fire here (UPDATEs touch paliad.deadlines, not deadline_rules),
but the wrapper is the standard pattern.
Phase 3 Slice 10 staging table for the fuzzy-match orphans mig 090
produces (design §3.I + m's Q10 ruling). Each legacy deadline that
the matcher can't uniquely bind to a deadline_rule logs here with
the full candidate list so a legal-review pass can hand-link the
ambiguous tail without rerunning the match.
Schema:
- deadline_id FK to paliad.deadlines (ON DELETE CASCADE).
- title + project_id + proceeding_code denormalised so the admin
orphan-review UI groups + filters without re-joining.
- reason text CHECK in ('no_match', 'ambiguous', 'no_project',
'manual_unbound'). Mig 090 writes the first two; the editor
surface (Slice 11) may add the others.
- candidate_count + candidate_rule_ids carry the full list of
plausible rules so the legal-review UI can render "pick one"
chips from the matcher's actual output.
- resolved_at + resolved_rule_id flip when an editor binds the
row via the admin UI; the matching paliad.deadlines.rule_id
UPDATE happens at the same time. Both rows hold so the staging
table doubles as an audit trail of the legal-review pass.
Indexes:
- deadline_id for the per-deadline lookup the admin UI uses.
- unresolved_at DESC for the "open orphans" list (the only one
the legal-review UI typically lists).
RLS: admin-only read. The orphan list contains real deadline titles
+ project ids, so non-admins must not see it. Service-layer surfaces
(Slice 11) gate further.
Mig 089 ships the table; mig 090 does the fuzzy-match backfill +
populates this table. Numbering reflects the dependency order (the
backfill SELECTs INTO this table, so the table must exist first).
Phase 3 Slice 8 wire-shape swap. UIDeadline gains:
- Priority: 4-way enum (mandatory|recommended|optional|informational)
— the authoritative field the frontend reads after Slice 8 to drive
save-modal pre-check + notice-card rendering.
- ConditionExpr: jsonb gate predicate (design §2.4 long form),
emitted verbatim as json.RawMessage so the rule editor (Slice 11)
+ admin surfaces can render the gating shape.
Additivity invariant: the legacy IsMandatory / IsOptional pair stays
populated via wireFlagsFromPriority (mandatory→T/F, optional→T/T,
recommended|informational→F/F). Pre-Slice-8 frontends keep working;
Slice 9 drops the legacy fields once the frontend cutover is verified
in prod.
All three calculator paths populate the new fields:
- FristenrechnerService.Calculate (proceeding-tree, Pipeline A)
- FristenrechnerService.calculateByTriggerEvent (Pipeline C)
- EventTriggerService.Trigger (event-keyed endpoint, Slice 6)
Backend live-DB test asserts:
- Every UPC_INF rule's priority is in the unified enum.
- The wireFlagsFromPriority round-trip holds for every row.
- At least one rule carries a populated conditionExpr (the 17
with_ccr / with_amend / with_cci rules from mig 084).
Phase 3 Slice 8 part 1 — wire the instance_level data field (mig 080
column, shipped in Slice 1) through the project service + handler.
- CreateProjectInput / UpdateProjectInput gain InstanceLevel *string.
Empty string is the explicit "clear" sentinel.
- validateInstanceLevel + nullableInstanceLevel helpers mirror the
OurSide pattern. Allowed values per mig 080 CHECK: 'first' |
'appeal' | 'cassation' | NULL.
- Service rejects bad values with ErrInvalidInput (existing handler
error-mapping surfaces this as HTTP 400 with the standard message).
- projectColumns SELECT now includes instance_level so reads
populate the field; Project struct already has the field from
Slice 1.
- handleCreateProject accepts instance_level from the raw map; Update
handler uses the standard JSON decoder into UpdateProjectInput.
Live-DB test exercises:
- Create with instance_level='first' → roundtrips.
- Update to 'appeal' → roundtrips.
- Update to '' → NULL after the trip.
- Update to 'supreme' → ErrInvalidInput.
The DB CHECK on mig 080 is the defence-in-depth backstop should an
SQL-direct INSERT bypass the service.
Live-DB test for the Phase 3 Slice 7 spawn wiring. Seeds three
synthetic proceedings (SLICE7_TEST_A/B/C) + rules under them, with
audit-reason wrappers so the mig 079 trigger writes informative
audit rows during seed / cleanup. Three scenarios:
1. A → B single spawn. Expansion emits one spawned-into row whose
RuleCode matches B's root rule. DependsOnRuleCode references
A's spawn rule; DependsOnDate is parsed from the synthetic
UIDeadline date (2026-03-15); Track="spawn" so the frontend
boundary divider lights up. DeadlineRuleID points at B's
root rule UUID.
2. Cycle A → B → A. Adds a spawn rule on B back to A; rerun
expansion → ErrCyclicSpawn surfaces (errors.Is matches). The
visited-set guard catches the second-hop attempt to recurse
into A which is already in the chain. No infinite loop.
3. Multi-spawn defensive. Drops the cycle edge, adds a second
spawn rule on A targeting C. Expansion emits two spawned-into
rows (B's root + C's root); the test asserts both RuleCodes
appear in the output regardless of order.
Cleanup: WHERE name LIKE 'SLICE7_TEST_%' AND code LIKE
'SLICE7_TEST_%' so production rules are untouched. audit_reason
set before every INSERT/DELETE so the mig 079 trigger doesn't
reject the seed transactions.
Build clean, full local test suite green; this test skips when
TEST_DATABASE_URL is unset.
Phase 3 Slice 7 Step G (design §6). Closes the half-finished
projection_service.go:896-901 spawn-skip from the t-178 audit.
What lands:
- DeadlineRuleService.ListByProceedingTypeIDs(ids): bulk-load
rules for a set of spawn-target proceedings in one round-trip.
Skips hydrateConceptDefaultEventTypes (SmartTimeline doesn't
need concept-default event_types on spawned rows). Pre-sorted
by (proceeding_type_id, sequence_order) so callers pick the
target's root rule via the first slot per proceeding.
- ProjectionService.expandCrossProceedingSpawns: walks the spawn
graph rooted at the project's source proceeding. For each rule
with is_spawn=true AND a non-NULL spawn_proceeding_type_id,
resolves the target proceeding's root rule and emits a
spawned-into TimelineEvent with:
Kind="projected", Track="spawn", Status="predicted",
DependsOnRuleCode=<source.code>, DependsOnRuleName=<source.name>,
DependsOnDate=<source's computed due date when available>.
SpawnLabel on the source rule, if set, is appended to the
target title as "<target name> (<spawn_label>)".
- Cycle guard: visited-set DFS keyed by proceeding_type_id. The
source proceeding is seeded into `visited` before the walk;
when any spawn's target is already in `visited`, the helper
returns ErrCyclicSpawn with rule + proceeding context. The
caller (computeProjections) catches the error and degrades to
"no spawned rows" — better than failing the whole projection.
ProjectionMeta.SpawnCycleDropped surfaces the degradation so
the caller can log + show a "Spawn-Auflösung übersprungen"
banner.
- Recursion: expandCrossProceedingSpawns recurses into the
target proceeding's spawn rules (depth+1) so a chain
A → B → C surfaces every hop. maxSpawnDepth (4) is a safety
belt on top of the visited-set guard.
Live data semantics: the live corpus has 6 active is_spawn=true
rules — AMD.ccr.amend, AMD.rev.amend, APP.ccr.appeal,
APP.inf.appeal, APP.rev.appeal, CCR.ccr.counterclaim. ALL six have
spawn_proceeding_type_id IS NULL today, so the live SmartTimeline
emits zero spawned-into rows. Slice 7 wires the code path; the
backfill of spawn_proceeding_type_id on these 6 rules is a
separate concern (the design doc's mig 093 was deferred — the
litigation-category proceedings these rules sit in were retired
from project-binding in Slice 5).
Calculator stays scoped (Option A, design §6.2): the unified
FristenrechnerService.Calculate does NOT follow spawns. The
SmartTimeline projection service is the sole consumer that chains
across proceedings. UIResponse.Deadlines for a proceeding only
contains rules from that proceeding; spawn resolution happens at
the projection layer.
projection_service.go:896-901 comment updated to reflect the new
post-Slice-7 reality (calculator stays scoped; spawned rules
arrive via expandCrossProceedingSpawns, not via the calculator's
Deadlines list).
Live-DB test (TEST_DATABASE_URL-gated) for the Phase 3 Slice 6
endpoint covering:
1. Missing both event_type_id + concept_id → ErrInvalidInput.
2. Malformed trigger_date → ErrInvalidInput.
3. Unknown event_type_id → ErrInvalidInput.
4. event_type_id only → parity proxy against
EventDeadlineService.Calculate (Slice-3 legacy delegate). Both
code paths share the unified backend post-Slice-4 so the
returned rule-name multiset must be identical. Selects the
test fixture live: ANY event_type with a non-empty
trigger_event_id bridge to active deadline_rules.
5. concept_id only → returns rules linked by concept_id FK.
Picks the concept with the most rules so we exercise the
ordering path (proceeding_type_id NULLS LAST,
sequence_order). Spot-checks each rule's RuleID parses as UUID.
6. event_type_id + concept_id together → UNION dedupe. Today's
corpus has the two paths on disjoint rule sets so the
additive-count assertion holds; if a future seed links a
concept to a Pipeline-C rule, the dedupe branch fires and the
test logs (not fails) the count divergence for review.
7. Perspective filter — locates a concept with both claimant and
defendant rules (skips gracefully when the corpus lacks one)
and asserts the defendant-perspective response omits every
claimant-party rule.
Build clean, full local test suite green; this test skips when
TEST_DATABASE_URL is unset.
Phase 3 Slice 6 handler. Decodes JSON body (eventTypeId, conceptId,
triggerDate, flags, courtId, perspective), validates required
fields (triggerDate + at least one identifier), parses UUIDs (400
on malformed), delegates to EventTriggerService.Trigger, surfaces
ErrInvalidInput as 400 with the service's German user-facing
message.
Wiring:
- dbServices gains an eventTrigger pointer (handlers package
internal type) wired from handlers.Services.EventTrigger.
- handlers.Services.EventTrigger is the new exported field; the
bundle constructor in main.go fills it from
NewEventTriggerService(pool, rules, holidays, courts).
- Route registered as POST /api/tools/event-trigger on the
protected mux, sibling to the existing /api/tools/fristenrechner
and /api/tools/event-deadlines endpoints.
Returns 503 when DATABASE_URL is unset (matches every other
calculator endpoint's behaviour). Returns same JSON shape as
/api/tools/fristenrechner so the frontend can render with the
existing timeline renderer.
Phase 3 Slice 6 (design §5) — service-side implementation of the new
unified event-trigger entry point. Accepts (event_type_id?,
concept_id?, trigger_date, flags?, court_id?, perspective?) and
returns the same UIResponse the proceeding-tree calculator emits.
Rule discovery:
- event_type_id → SELECT paliad.event_types.trigger_event_id →
DeadlineRuleService.ListByTriggerEvent (Pipeline-C path, post-
Slice-3 unified backend).
- concept_id → DeadlineRuleService.ListByConcept (new method on
the rule service: SELECT deadline_rules WHERE concept_id = $1
AND is_active = true). Direct FK lookup; Pipeline-A cascade
leaf semantic.
- Both → UNION deduped by rule.id (seen-set in Go; small rule
sets, no SQL DISTINCT overhead).
- Validation: at least one of the two must be set;
ErrInvalidInput otherwise. Unknown event_type_id also bubbles
as ErrInvalidInput (404-style).
Math reuses the Slice-4 unified helpers verbatim:
- applyDuration(base, value, unit, timing, country, regime, holidays)
- evalConditionExpr(expr, condition_flag, flags) — long-form
jsonb gate with legacy AND-of-array fallback.
- wireFlagsFromPriority(priority) — derives IsMandatory + IsOptional
so the wire shape stays calibrated against /api/tools/fristenrechner.
Composite combine_op (max/min) + legacy alt-swap-on-flag are
applied in the same mutually-exclusive order the proceeding-tree
calculator uses (combine_op IS NULL ⊕ alt-swap-on-flag-met).
matchesPerspective filter is permissive: empty perspective →
pass-through; NULL party → pass-through; only drops on explicit
claimant↔defendant mismatch. Court / both / NULL rules always
render.
is_court_set rules surface IsCourtSet=true and clear the computed
date — matches the proceeding-tree calculator's "wird vom Gericht
bestimmt" rendering.
UIResponse.ProceedingType / ProceedingName stay empty (caller
already has the event-type / concept context); same contract
calculateByTriggerEvent uses.
DeadlineRuleService.ListByConcept: ORDER BY proceeding_type_id NULLS
LAST, sequence_order so a multi-proceeding concept doesn't
interleave its constituent rules in the timeline.
Live-DB test (TEST_DATABASE_URL-gated) for Phase 3 Slice 5 that
covers the full chain:
1. Migration smoke: post-mig 087, no project points at a
non-fristenrechner-category proceeding_types row.
2. ProjectService.Create with a litigation-category id returns
ErrInvalidProceedingTypeCategory (service-layer guard fires
before any DB write).
3. mig 088 trigger rejects a raw INSERT that bypasses the Go
service — defence-in-depth assertion. Errors on the trigger
raise; test asserts a non-nil error.
4. Fristenrechner-category id (UPC_INF) succeeds. The created
project carries the expected proceeding_type_id.
The four sub-assertions hit each layer of the guard chain (picker
filter → service guard → DB trigger) plus the migration smoke. Any
regression in the chain surfaces here before the deploy.
Tests + main build clean; live test skips when TEST_DATABASE_URL
is unset.
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 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.
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.
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.
Slice 1 backend slice. Tiny static-file server for the new standalone
chart page; visibility piggybacks on the existing /api/projects/{id}/
timeline endpoint (gated through ProjectionService.For), so no new
auth surface.
Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
Two regressions from SmartTimeline Slices 2-4 dogfood @ 2026-05-09:
m/paliad#32 — clicking timeline_status / timeline_track / project_event_kind
chips changed URL params but the rendered list never narrowed. Two
causes: (1) the Verlauf bar mounted only "time" + "project_event_kind"
axes — the timeline_status / timeline_track chips never appeared. (2)
the customRunner drained predicates into `loadEvents` which writes the
legacy `events` array; the SmartTimeline render reads `timelineRows`,
so the filter pass was a dead branch.
Fix: mount all three axes on the bar; rewrite customRunner to drain
state into `verlaufFilters`; renderTimeline applies them client-side
via `applyTimelineRowFilters` before handing rows to renderSmartTimeline.
project_event_kind is forwarded through the substrate-shaped predicate
map (effective.filter.predicates.project_event.event_types);
timeline_status / timeline_track sit on raw BarState — the customRunner
signature now accepts the BarState snapshot as a second arg so the
bar's first run (before the handle is assigned) can read them.
Backend adds `ProjectEventType` to TimelineEvent + frontend
TimelineEvent — needed so the project_event_kind chip can match against
the underlying paliad.project_events.event_type for milestone rows.
m/paliad#33 — "Nur direkt" pill flipped subtreeMode and re-fetched the
timeline with ?direct_only=true, but ProjectionService.For honoured the
flag only at the deadline / appointment / project_events SQL level. CCR
sub-project lanes (Slice 3) and child-case lanes (Slice 4) loaded
unconditionally, so the "direct" view still showed everything.
Fix: `For` short-circuits to `forDirectSelfOnly` whenever DirectOnly is
set. Single "self" lane, no CCR / parent_context / child-case
aggregation. The level-policy kind/status filter still applies at
higher levels so a Patent-level direct view doesn't leak off_script
custom milestones the aggregated view filters out.
Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live
pin the contract — Patent direct_only collapses to a single 'self' lane
and excludes child-case events; Case-A direct_only excludes the CCR
child's milestones (with subtree default still surfacing them).
Build: go build/vet/test clean. bun run build clean (2171 keys).
ProjectionService now dispatches on project type per design §5.1:
- Case (and unknown) — full detail flow: parent track + CCR sub-projects
+ parent_context for CCR children. Lanes mirror tracks ("self" +
"counterclaim:<id>" + "parent_context:<id>").
- Patent / Litigation / Client — lane-aggregated: load direct children
matching the axis (cases / patents / litigations), gather subtree
events per lane, apply (kinds, statuses) filter, tag rows with
LaneID = direct-child id. Calculator skipped at higher levels —
predicted future is a Case-level concern.
levelPolicy(projectType) returns the (kinds, statuses, lane_axis)
triple. Patent = deadlines+milestones with done/open/overdue;
Litigation + Client = milestones with done.
metadata.bubble_up on paliad.project_events (no schema change — uses
existing jsonb column) overrides the kind/status filter at higher
levels. Defaults per Q5: counterclaim_created / third_party_intervention
/ scope_change → true; custom_milestone → false (user opts in via
form checkbox). insertCounterclaimEvent now sets bubble_up=true on
both parent + child audit rows so the counterclaim_created milestone
surfaces at Patent / Litigation / Client.
Wire shape changed from []TimelineEvent to envelope {events, lanes} —
lane metadata can ride alongside the rows without exceeding header-
size limits when a Client-level projection has many lanes. Frontend
reads .events for the per-row contract and .lanes for parallel-column
rendering. X-Projection-* headers preserved for Slice 1-3 affordances
(lookahead toggle, track chip).
RecordCustomMilestone gains a bubbleUp bool param; persisted to
metadata.bubble_up only when true (so existing rows-without-it keep
the default-off behaviour).
Tests: TestLevelPolicy locks the triple table; TestRowSurvivesPolicy_
BubbleUpOverridesFilter pins the override contract; TestExtractBubbleUp
covers all per-event-type defaults + explicit override paths;
TestChildTypeForAxis pins the axis → type map. Live integration test
TestProjectionService_LevelAggregation_Live walks the patent-level
fixture: bubbled-up milestone surfaces, regular custom_milestone is
filtered, deadlines surface at Patent level.
Refs: docs/design-smart-timeline-2026-05-08.md §5 + §10 Slice 4
Refs: m/paliad#31, t-paliad-175
ProjectionService.For now composes multiple tracks instead of a single
"parent" stream. The viewed project always emits Track="parent"; visible
CCR children emit Track="counterclaim:<child_id>"; a project that is
itself a CCR (counterclaim_of != nil) pulls its target's events as
Track="parent_context:<parent_id>" so the lawyer working the CCR sees
the main proceeding without leaving the page (§4.5).
Each track runs the actuals + projection pipeline independently with
its own lookahead cap and dependency annotations against its own
proceeding's rule tree. SubProjectID + SubProjectTitle are populated on
non-parent rows so the frontend can render the sub-project title in the
column sub-header.
ProjectionMeta gains AvailableTracks; the handler surfaces it as the
new X-Projection-Tracks response header (CSV) so the wire shape stays
[]TimelineEvent (frozen since Slice 1).
POST /api/projects/{id}/counterclaim wraps ProjectService.CreateCounterclaim
— accepts proceeding_type_id / flip_our_side / title / case_number,
returns the new project's id + canonical /projects/<id> URL.
Tests: pure-function coverage for derivedCounterclaimOurSide (default
flip + R.49.2.b override + court/both pass-through). Live-DB integration
test covers the four invariants — CreateCounterclaim atomicity (parent
audit + child audit + our_side flip + sibling-under-patent placement),
parent's projection surfaces the counterclaim track, child's projection
surfaces parent_context, two-level CCR chains are rejected by both the
service guard and the schema-level trigger.
Migration 077 adds paliad.projects.counterclaim_of (nullable FK ON DELETE
SET NULL) plus a partial index. A trigger function rejects two-level CCR
chains: a project with counterclaim_of NOT NULL cannot be the target of
another CCR — UPC practice has no CCR-of-a-CCR shape, so reject it at
the schema level rather than defending in the application layer.
ProjectService gains LoadCounterclaimChildrenVisible (list visible CCR
sub-projects against a parent) and CreateCounterclaim (atomic: project
row + creator-as-lead team membership + audit rows on parent AND child).
The CCR child is placed as a sibling under the same patent (§4.4), our
side flips claimant↔defendant by default with a "Stimmt nicht?" override
for the R.49.2.b CCI edge case, and the proceeding type defaults to
UPC_REV. Title auto-suggests from the patent ancestor's patent_number
when available.
Tracker advances 76 → 77.
shape-timeline.ts:
- Renders Kind="projected" rows with Status-driven styling: predicted
(faded grey), court_set (dashed border), predicted_overdue (amber
fade with overdue glyph).
- "[Datum setzen]" inline date editor on every projected row with a
rule_code. Submit POSTs /api/projects/{id}/timeline/anchor; 200
triggers onChange (re-fetch + re-render); 409 renders the
predecessor_missing payload as inline error with a "Stattdessen
<predecessor> erfassen" link that scrolls to + opens the parent's
editor.
- "Folgt aus: <Name> (<Code>, <Date|Datum offen>)" footer on every row
with depends_on_rule_code, plus "[Pfad anzeigen]" expander hint.
- "[+ Mehr anzeigen]" / "[− Weniger]" lookahead toggle when backend's
X-Projection-Total header indicates more projections exist beyond
the current cap.
- Status pills on projected rows surface the status nuance next to
the kind chip without overwhelming the title.
projects-detail.ts:
- loadTimeline reads X-Projection-{Total,Lookahead} headers and forwards
them to renderSmartTimeline.
- Lookahead state persisted in localStorage per project (key
`paliad.smarttimeline.lookahead.<id>`).
- Removes the renderEvents() orphan (band-aid from t-paliad-172) and
every call site — renderTimeline is the only project-page render
path now. Aligns with fermat's commit-message hint in 0835be4.
FilterBar (substrate):
- New axes timeline_status / timeline_track (chip clusters, multi-
select). Macro chip pair "Zukunft anzeigen" / "Nur vergangenes" on
the timeline_status axis maps to the predicted+court_set subset
on/off.
- url-codec round-trips ?tl_status= / ?tl_track= so saved Sichten /
bookmarks survive.
CSS:
- ~80 LoC for .smart-timeline-row--projected/--court_set/--predicted_overdue,
status pills, depends-on footer, anchor editor, lookahead toggle.
All tokens reuse existing CSS variables — no bare-hex fallbacks
(cf. t-paliad-150 dark-mode lesson).
i18n:
- 31 new keys (DE+EN) for projected statuses, depends-on labels,
anchor editor states, lookahead chips, FilterBar axis labels +
values + macro chips. 2102 → 2146 total.
Tests:
- projection_anchor_test.go covers applyLookaheadCap (overdue +
court_set exemption), applyLookaheadDefault clamping,
ruleAnchorKind dispatch, extractMetadataString, lang normalisation,
ruleNameInLang, PredecessorMissingError unwrap, annotateDependsOn
(including parent-of-parent chain dating).
Migration 076 was applied live during dev (tracker 75 → 76); deploy
re-applies idempotently via the embedded migrate path.