Commit Graph

784 Commits

Author SHA1 Message Date
mAi
6fc8c0136e feat(t-paliad-198): Slice 3 — mobile polish + inline search + tooltip polish
Closes the Determinator cascade redesign. Three intertwined pieces:

1. The mode row is gone — the `🔍 Direkt suchen` icon at the top of the
   row stack now toggles an inline search overlay over the cascade
   instead of routing to the legacy B2 surface. Results render into the
   same `#fristen-b1-results` container the cascade uses, so users see
   one consistent concept-card layout regardless of whether they
   reached the rule via cascade narrowing or free-text search. ESC
   inside the input clears it on the first press and collapses on the
   second; "← Zurück zum Entscheidungsbaum" restores cascade + state.
   Deep-link `?mode=filter` still routes to the legacy B2 panel for
   backwards-compatible shared URLs but is no longer exposed in the
   cascade UI.

2. Mobile responsive per design §7. Three breakpoints layer onto the
   `.fristen-row` primitive: <640px (phone — chips full-width single
   column, ändern permanently visible, answer wraps to its own line),
   <768px (tablet — head wraps so ändern moves down, chips
   single-column), <1024px (small desktop / large tablet — chips drop
   to 2-column auto-fill). Active row autoscrolls into view on every
   render with 60px headroom; the helper is a no-op when the row is
   already visible so desktop doesn't jitter.

3. Auto-walk tooltip polish: 200ms fade-in + slide-down via an
   is-entering transition state; mobile (<640px) flips the insertion
   point so the tip lands below the prefilled row rather than above;
   any chip pick or ändern click counts as user-engagement and
   dismisses the tip (in addition to the explicit × button).

Refs: docs/design-determinator-row-cascade-2026-05-13.md §6 + §7 + §10 Slice 3.
2026-05-16 00:58:02 +02:00
mAi
8b6b9254ed Merge: t-paliad-197 — Determinator row-cascade Slice 2 (project-driven narrowing + auto-walk) 2026-05-16 00:50:59 +02:00
mAi
a33060e600 feat(t-paliad-197): Slice 2 — project-driven narrowing + cascade auto-walk
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.
2026-05-16 00:50:27 +02:00
mAi
d7b2292f8f Merge: t-paliad-180 — Determinator row-cascade Slice 1 (visual hierarchy + row-by-row layout) 2026-05-16 00:38:58 +02:00
mAi
ff8f95abaa feat(t-paliad-180): Slice 1 — Determinator row-stack cascade
Replace the four-layer Pathway B mess (mode radio + perspective chip strip
+ inbox chip strip + breadcrumb cascade) with a single `.fristen-row`
primitive rendered in a top-down stack. Every decision — mode, perspective,
inbox, cascade depth N — now uses the same shape (label · picked answer ·
inline "ändern") and three states (is-active / is-answered / is-prefilled).

The user finally sees their full decision path at a glance instead of
chasing breadcrumb crumbs after each drill. Click on any answered row (or
its ändern affordance) re-actives it; ändern on a cascade depth drops the
descendants (same drop-descendants semantic as today's breadcrumb-click).
Reset link and `🔍 Direkt suchen` escape-hatch live at the top of the stack
per design §6 Option B; the mode-toggle radio is gone, routing to
?mode=filter now flows through the mode row.

Visual-only refactor — narrowing engine (inboxFilterAllowsForums +
perspectiveAllowsParty) is unchanged. Slice 2 will add project-driven
prefills + auto-walk; Slice 3 covers mobile polish and search relocation.

Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 1.
2026-05-16 00:38:19 +02:00
mAi
84aadc838a Merge: t-paliad-195 — Fristen Phase 3 Slice 9 (mig 091 legacy column drops; 092+093 deferred per live-data audit) 2026-05-15 17:55:18 +02:00
mAi
c4564b4031 refactor(t-paliad-195): drop priorityRendering legacy fallback
Phase 3 Slice 9 frontend cleanup. The backend's UIDeadline wire
shape stopped emitting (isMandatory, isOptional) in this slice;
the matching legacy-fallback branch in priorityRendering is now
dead code. Drops:

  - CalculatedDeadline TS interface: isMandatory + isOptional
    fields removed. `priority` is required (not optional) since
    every backend response now populates it.
  - priorityRendering(): collapsed to a clean switch on `priority`.
    Unknown priority falls back to "render as mandatory" (safe
    default; never silently drop a rule) — the legacy
    (isMandatory, isOptional) inference is gone.
  - Save-modal optional-badge rendering in fristenrechner.ts now
    reads `dl.priority === "optional"` directly (was previously
    `dl.priority === "optional" || dl.isOptional`).
  - Timeline row's optional-badge rendering in
    verfahrensablauf-core.ts switched from `!dl.isMandatory` to
    `dl.priority === "optional"`. Slightly different semantic —
    pre-Slice-9 the badge fired on every non-mandatory row
    (recommended + optional + informational); post-Slice-9 only
    on opt-in rules (RoP.151 pattern). Recommended + informational
    are surfaced via their own rendering tier (notice card for
    informational) so the badge change tightens the meaning.

Frontend build clean; no i18n keys removed (the priority labels
shipped in Slice 8 stay live).
2026-05-15 17:53:59 +02:00
mAi
7dae9b2216 test(t-paliad-195): adapt fixtures + assertions to post-drop shape
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.
2026-05-15 17:53:44 +02:00
mAi
99a72a744f refactor(t-paliad-195): drop legacy fields from Go service surface
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.
2026-05-15 17:53:31 +02:00
mAi
f9305d6108 feat(t-paliad-195): mig 091 — drop legacy rule columns
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.
2026-05-15 17:53:08 +02:00
mAi
7f72ee7b9e Merge: t-paliad-196 — orphan concept proposal doc (curie researcher draft for m's review) 2026-05-15 17:48:05 +02:00
mAi
d027b0874c docs(t-paliad-196): orphan-concept seed proposals (Fristen Phase 3 Slice 12, draft)
5 live orphans (not 9 — discrepancy flagged), 7 linkage-only UPDATEs and
12 net-new rule drafts. Sources cited; 12 FLAGs for m's review before
/admin/rules ingest.
2026-05-15 17:47:30 +02:00
mAi
7571e43078 chore(t-paliad-194): wire aichat env vars through docker-compose.yml
The Dokploy compose .env file got the new vars during the operational
flip but the docker-compose.yml environment block didn't list them, so
docker-compose silently dropped them during container start.

Adds PALIADIN_BACKEND / AICHAT_URL / AICHAT_TOKEN / AICHAT_PERSONA to
the environment block with safe defaults (PALIADIN_BACKEND=legacy,
AICHAT_PERSONA=paliadin). Existing deployments without aichat envs set
keep the legacy path; flipping PALIADIN_BACKEND=aichat in Dokploy now
takes effect on next deploy.

Discovered while doing the aichat Phase B activation flip.
2026-05-15 17:33:20 +02:00
mAi
c7b48f6ea7 Merge: t-paliad-194 / m/paliad#38 — aichat Phase B paliad migration (PALIADIN_BACKEND=aichat opt-in) 2026-05-15 03:04:56 +02:00
mAi
8f6cee5a83 chore(t-paliad-194): delete paliad-side paliadin skill bundle (SoT moved to m/mAi)
Per m's 2026-05-13 decision (m/mAi#207 §13 Q4): the paliadin SKILL.md
and references/sql-recipes.md are now owned by aichat. The aichat repo
already has the equivalents committed at skills/aichat/paliadin/ on
mai/darwin/issue-207-aichat (verified before this commit). Aichat's
own deploy doc handles installation on mRiver.

Deleted:
  scripts/skills/paliadin/SKILL.md
  scripts/skills/paliadin/references/sql-recipes.md
  scripts/install-paliadin-skill

Legacy LocalPaliadinService / RemotePaliadinService still depend on
~/.claude/skills/paliadin/ being present on whichever host they run
against. Until those paths retire (Phase C / Q15), operators install
the skill manually from m/mAi/skills/aichat/paliadin/.

CLAUDE.md updated:
  - PALIADIN_SESSION_PREFIX row points readers at m/mAi for the skill
    SoT and notes the legacy paths still expect a manual install.
  - New env-var rows for PALIADIN_BACKEND / AICHAT_URL / AICHAT_TOKEN /
    AICHAT_PERSONA so the operator runbook for the Phase B flip is
    self-contained.
2026-05-15 03:03:49 +02:00
mAi
edc81bbbc2 feat(t-paliad-194): AichatPaliadinService + PALIADIN_BACKEND=aichat env gate (m/paliad#38 Phase B)
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.
2026-05-15 03:03:34 +02:00
mAi
08e20883a5 feat(t-paliad-194): revive per-turn JWT mint for Paliadin (folded-in t-paliad-156)
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.
2026-05-15 03:03:12 +02:00
mAi
86946ba441 Merge: t-paliad-192 — Fristen Phase 3 Slice 11b (rule editor FRONTEND — admin UI on /admin/rules) 2026-05-15 02:10:19 +02:00
mAi
193b988798 feat(t-paliad-192): admin rule-editor frontend (Slice 11b)
Surfaces the Slice 11a admin API at /admin/rules so editors can drive
the rule lifecycle without curling. Three new pages, each gated by
adminGate on the route + sidebar reveal via /api/me:

  /admin/rules              — list page with filters (proceeding,
                              trigger event, lifecycle chips, fuzzy
                              search) and a second "Orphans" tab that
                              loads paliad.deadline_rule_backfill_orphans
                              via the new GET /admin/api/orphans
                              endpoint. Pick-chip on each candidate
                              fires the reason modal → POST resolve.
                              "+ Neue Regel" opens the same reason modal
                              with minimal required fields (name DE/EN
                              + duration) and routes to the edit page
                              on success.

  /admin/rules/{id}/edit    — full form (37 columns grouped: identity /
                              proceeding / timing / party / display /
                              lifecycle / condition). Side panel hosts
                              the preview widget (trigger date + flags
                              → GET .../preview, drafts only) and the
                              audit-log timeline (paginated, 20 per
                              page). Bottom action bar adapts to
                              lifecycle_state — save-draft + publish on
                              drafts, clone on published/archived,
                              archive on draft/published, restore on
                              archived. Every action opens the reason
                              modal with ≥10-char client-side guard per
                              Slice 11a edge case #4.

  /admin/rules/export       — minimal SQL preview + "Download as file"
                              / "Copy to clipboard". Optional `since`
                              audit-id scopes the export window.

condition_expr ships with a raw JSON textarea + inline parse
validation; the tree-builder is out of scope for Slice 11b (raw JSON
is sufficient given the existing 172-row corpus and validates the
same grammar live). The dependency on document.querySelectorAll for
form binding follows the admin-event-types / admin-audit-log
playbook — no new component substrate needed.

Wiring:
  - frontend/build.ts: 3 new entrypoints + 3 new HTML writes.
  - frontend/src/admin.tsx: new "Regeln verwalten" card with ICON_TABLE.
  - frontend/src/components/Sidebar.tsx: two new admin nav entries
    (Regeln + Regel-Migrations).
  - frontend/src/client/i18n.ts: 162 new keys (DE+EN), under
    admin.rules.* and admin.rules.edit.* and admin.rules.export.*.
  - frontend/src/styles/global.css: new admin-rules-* CSS block
    appended (chips, pills, audit timeline, edit-grid, preview list,
    orphan cards, export pre). Uses paliad's existing CSS tokens so
    light/dark/auto themes inherit automatically.

Route registration:
  - GET /admin/rules                — list page shell
  - GET /admin/rules/{id}/edit      — edit page shell
  - GET /admin/rules/export         — export page shell

All routes adminGate + gateOnboarded, so non-admin users 404 before
the shell even loads. Backend audit and lifecycle invariants from
Slice 11a stay authoritative; the frontend never bypasses them.
2026-05-15 02:09:35 +02:00
mAi
1c45c93570 feat(t-paliad-192): admin orphan list/resolve endpoints
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.
2026-05-15 02:09:10 +02:00
mAi
36bdfecb04 Merge: t-paliad-191 — Fristen Phase 3 Slice 11a (rule editor backend — admin API + lifecycle + audit + preview) 2026-05-15 01:51:28 +02:00
mAi
936c4967fd test(t-paliad-191): rule-editor lifecycle + preview coverage
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.
2026-05-15 01:50:29 +02:00
mAi
7decc5095f feat(t-paliad-191): admin rule-editor HTTP API
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.
2026-05-15 01:50:15 +02:00
mAi
b21ce6dd7b feat(t-paliad-191): RuleEditorService — admin rule lifecycle
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.
2026-05-15 01:50:03 +02:00
mAi
358c64d172 feat(t-paliad-191): CalcOptions.RuleOverrides + applyRuleOverrides
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.
2026-05-15 01:49:43 +02:00
mAi
5d22e5db21 Merge: t-paliad-190 — Fristen Phase 3 Slice 10 (rule_id backfill + orphan staging) 2026-05-15 01:38:47 +02:00
mAi
09615ec48e feat(t-paliad-190): mig 090 — one-time fuzzy-match backfill
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.
2026-05-15 01:37:57 +02:00
mAi
5431fcd3cd feat(t-paliad-190): mig 089 — deadline_rule_backfill_orphans staging
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).
2026-05-15 01:37:34 +02:00
mAi
16ae2f0cf0 Merge: t-paliad-189 — Fristen Phase 3 Slice 8 (wire shape swap + instance_level data + notice cards) 2026-05-15 01:30:07 +02:00
mAi
4c3d091280 feat(t-paliad-189): priority-driven save modal + notice cards
Phase 3 Slice 8 frontend wire-shape swap. Save-modal pre-check logic
moves from the legacy (isMandatory, isOptional) pair to the unified
priority enum via a new priorityRendering helper in
verfahrensablauf-core.ts:

  - mandatory   → pre-checked, save button visible
  - recommended → pre-checked, save button visible
  - optional    → pre-unchecked, save button visible (RoP.151 pattern)
  - informational → NO save button — renders as a notice card with a
    "Hinweis" / "Note" label, distinct visual tier (no checkbox).
    The visible UX win of Phase 3: the 18 F/F filing rules
    (Berufungserwiderung, Replik, Duplik, R.19, R.116 EPÜ, etc.)
    currently render as 'recommended'; once editorial review flips
    them to 'informational' via the rule editor (Slice 11), this
    branch lights up and they stop offering a save action that
    would auto-create deadlines users didn't ask for.

priorityRendering falls back to the legacy (isMandatory, isOptional)
pair semantic when priority is missing (pre-Slice-8 backend
responses), so the cutover is bidirectional-safe. After Slice 9
drops the legacy fields, the fallback branch becomes unreachable.

CalculatedDeadline TS interface gains:
  - priority: optional 4-way union literal type
  - conditionExpr: optional unknown (rule editor reads this; the
    save-modal doesn't need to interpret it)

i18n keys added (DE + EN both):
  - deadlines.priority.mandatory/recommended/optional/informational
  - deadlines.priority.informational.notice_label (Hinweis / Note)
  - project.instance_level.first/appeal/cassation/unset
  - verlauf.spawn.chip + verlauf.spawn.cycle_warning (reserved for
    the SmartTimeline spawn-chip work, deferred to a focused
    follow-up so this slice doesn't balloon)

Frontend build clean (2225 i18n keys, 11 new). The instance_level
pill group on the project-edit form is intentionally NOT shipped
in this slice — the project-edit form is large and the pill is
self-contained UI; the data field is exposed via the API and a
follow-up slice (or the rule editor work) can wire the picker
without blocking the wire-shape swap.
2026-05-15 01:29:13 +02:00
mAi
d6f5e0c97e feat(t-paliad-189): UIResponse emits priority + conditionExpr
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).
2026-05-15 01:28:56 +02:00
mAi
a55f45ebea feat(t-paliad-189): instance_level on project Create/Update
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.
2026-05-15 01:28:45 +02:00
mAi
6f77c8354c Merge: t-paliad-188 — Fristen Phase 3 Slice 7 (cross-proceeding spawn wiring + cycle guard) 2026-05-15 01:19:11 +02:00
mAi
b64d929586 test(t-paliad-188): spawn expansion + cycle guard + multi-spawn
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.
2026-05-15 01:18:18 +02:00
mAi
e30bfe89da feat(t-paliad-188): cross-proceeding spawn wiring + cycle guard
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).
2026-05-15 01:18:07 +02:00
mAi
d8edea0f4c Merge: t-paliad-187 — Fristen Phase 3 Slice 6 (POST /api/tools/event-trigger endpoint) 2026-05-15 01:10:09 +02:00
mAi
65617a5dcb test(t-paliad-187): EventTriggerService integration coverage
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.
2026-05-15 01:09:31 +02:00
mAi
7bfec310a0 feat(t-paliad-187): POST /api/tools/event-trigger handler + wiring
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.
2026-05-15 01:09:20 +02:00
mAi
253dc1d1b3 feat(t-paliad-187): EventTriggerService.Trigger
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.
2026-05-15 01:09:11 +02:00
mAi
992b99c375 Merge: t-paliad-186 — Fristen Phase 3 Slice 5 (projects soft-merge to fristenrechner codes only) 2026-05-15 01:02:33 +02:00
mAi
7afbf52f3e test(t-paliad-186): proceeding-type category guard
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.
2026-05-15 01:01:46 +02:00
mAi
663ef64c62 feat(t-paliad-186): project picker filters to fristenrechner only
Phase 3 Slice 5 frontend. loadProceedingTypes() in projects-detail.ts
now fetches /api/proceeding-types-db?category=fristenrechner so the
project edit picker only ever shows the 19 fristenrechner codes,
never the 7 legacy litigation codes (INF / REV / CCR / APM / APP /
AMD / ZPO_CIVIL).

The Fristenrechner calculator page + Verfahrensablauf page are NOT
touched — they still need the full proceeding_types catalog (the
litigation codes have rule trees the calculator can render, per
design §3.F: "litigation codes stay … reachable via cascade leaves").
Only the project-binding picker is restricted.

Defence-in-depth: even if a future fetch bypasses this filter, the
server-side service guard (ErrInvalidProceedingTypeCategory) and
the mig 088 DB trigger both reject the write. The picker filter is
the UX layer of the chain — invisible bad-shape inputs.

projects-new.ts has no proceeding-type field today (the form lives
on the edit page only); no change needed there.
2026-05-15 01:01:37 +02:00
mAi
5b81f2159e feat(t-paliad-186): service guard + ?category filter
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.
2026-05-15 01:01:28 +02:00
mAi
275cbd5e51 feat(t-paliad-186): mig 088 — fristenrechner-category trigger
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.
2026-05-15 01:01:17 +02:00
mAi
76cbc311ed feat(t-paliad-186): mig 087 — remap projects.proceeding_type_id
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).
2026-05-15 01:01:08 +02:00
mAi
0f142e07af Merge: t-paliad-185 — Fristen Phase 3 Slice 4 (calculator unification — foundation chain complete) 2026-05-15 00:54:01 +02:00
mAi
d7bb238e46 test(t-paliad-185): table-driven unit tests for new helpers
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).
2026-05-15 00:53:01 +02:00
mAi
990cc2b797 refactor(t-paliad-185): unified calculator (Slice 4 Step D)
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.
2026-05-15 00:52:49 +02:00
mAi
650d30f99f Merge: t-paliad-184 — Fristen Phase 3 Slice 3 (Pipeline C migration + EventDeadlineService delegate) 2026-05-15 00:42:55 +02:00
mAi
6cddb2e587 test(t-paliad-184): 77-row Pipeline-C parity assertion
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.
2026-05-15 00:41:29 +02:00