Commit Graph

291 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
mAi
8a814e3442 refactor(t-paliad-184): EventDeadlineService.Calculate delegates
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.
2026-05-15 00:41:20 +02:00
mAi
5f9a8b2ef4 feat(t-paliad-184): FristenrechnerService.calculateByTriggerEvent
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.
2026-05-15 00:41:10 +02:00
mAi
ee2caf9d79 feat(t-paliad-184): mig 086 — event_deadlines read-only trigger
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.
2026-05-15 00:40:59 +02:00
mAi
88d5656a35 feat(t-paliad-184): mig 085 — Pipeline C data-move (77 rows)
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.
2026-05-15 00:40:50 +02:00
mAi
32a620b788 test(t-paliad-183): assert backfill integrity for Slice 2
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).
2026-05-15 00:29:10 +02:00
mAi
9d73b91e05 feat(t-paliad-183): mig 084 — backfill condition_expr per design §2.4
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).
2026-05-15 00:29:00 +02:00
mAi
b966d7c8cd feat(t-paliad-183): mig 083 — backfill priority per design §2.3
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.
2026-05-15 00:28:49 +02:00
mAi
755a1042ff feat(t-paliad-183): mig 082 — backfill is_court_set from heuristic
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.
2026-05-15 00:28:38 +02:00
mAi
1f8230b264 feat(t-paliad-182): models + service compat-read for unified rules
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).
2026-05-15 00:19:49 +02:00
mAi
bd8ec42b80 feat(t-paliad-182): mig 080 — projects.instance_level
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.
2026-05-15 00:19:37 +02:00
mAi
ec0ec32271 feat(t-paliad-182): mig 079 — deadline_rule_audit table + trigger
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.
2026-05-15 00:19:31 +02:00
mAi
251f5a250f feat(t-paliad-182): mig 078 — unified rule columns
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).
2026-05-15 00:19:19 +02:00
mAi
83a3d27fe0 feat(t-paliad-177): ShapeTimeline enum + render_spec wiring
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.
2026-05-15 00:06:37 +02:00
mAi
adf377c2ca Merge: t-paliad-179 Slice 1 — Tools surface split (route + shell + code-lift) 2026-05-13 00:20:44 +02:00
mAi
0099e2f28c feat(t-paliad-179): register /tools/verfahrensablauf + 302 legacy ?path=a
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.
2026-05-13 00:18:42 +02:00
mAi
d8f7745f86 feat(t-paliad-177): chart export — iCal feed (deadlines+appointments only)
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.
2026-05-13 00:11:14 +02:00
mAi
968b0bc2da feat(t-paliad-177): close visibility leak on /projects/{id}/chart handler
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.
2026-05-13 00:03:45 +02:00
mAi
b0a6b0998f feat(t-paliad-177): chart page handler + GET /projects/{id}/chart route
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.
2026-05-12 14:05:52 +02:00
m
c2f1c29b10 fix(t-paliad-176): FilterBar timeline narrowing + Nur-direkt subtree skip
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).
2026-05-09 18:52:01 +02:00
m
7da8802f9b feat(t-paliad-175): SmartTimeline Slice 4 — backend levelPolicy + lane aggregation + bubble-up
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
2026-05-09 16:22:07 +02:00
m
82888dea78 feat(t-paliad-174): SmartTimeline Slice 3 — projection parallel tracks + counterclaim handler
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.
2026-05-09 16:07:37 +02:00
m
306bb11618 feat(t-paliad-174): SmartTimeline Slice 3 — counterclaim sub-project schema + service
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.
2026-05-09 16:07:17 +02:00
m
331efc8603 feat(t-paliad-173): SmartTimeline Slice 2 frontend + #31 layered features
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.
2026-05-09 15:43:22 +02:00