Commit Graph

338 Commits

Author SHA1 Message Date
mAi
5dacc97a6b feat(dashboard): t-paliad-219 Slice A4 — frontend widget dispatch + inbox-approvals
Wire the configurable dashboard end-to-end on the frontend side. Factory
render only (edit mode is Slice B).

dashboard.tsx:

- Add data-widget-key to every section that participates in the layout
  (deadline-summary, matter-summary, upcoming-deadlines, upcoming-
  appointments, inline-agenda, recent-activity, inbox-approvals).
- New inbox-approvals section markup with summary line, list, empty
  state, and full-inbox link.
- Triple hydration placeholder: data + layout + catalog spliced as
  separate window.__PALIAD_DASHBOARD_* globals.

dashboard_shell.go + dashboard.go:

- Three placeholder splice instead of one. splicePlaceholder() helper
  consolidates the JS-assignment encoding.
- handleDashboardPage pre-fetches the user's saved layout via
  dashboardLayout.GetOrSeed and inlines the WidgetCatalog (code-
  resident — always inlined so the widget picker can boot on knowledge-
  platform-only deploys too).

dashboard.ts client:

- New InboxSummary / InboxEntry / DashboardLayoutSpec / DashboardWidgetRef
  types mirroring the Go shapes.
- settingsFor(key) reads per-widget settings (count, horizon_days) from
  the active layout; defaults fall back to catalog values.
- Existing renderers (Deadlines, Appointments, Activity, Agenda) thread
  count + horizon settings — backend now returns 60d / LIMIT 40 so the
  client narrows per the user's widget config.
- New renderInbox() renders the inbox-approvals widget with summary
  copy ("N offene Freigaben warten auf dich"), top-N entry list, and
  the empty state.
- applyLayout() walks the saved spec and (a) hides widgets whose
  layout entry is visible:false and (b) reorders visible widgets via
  parent.appendChild within their existing parent — preserves the
  .dashboard-columns 2-up grid for deadlines+appointments.
- filterByHorizonDays() filters list items by date relative to today.
- Boot wiring: read __PALIAD_DASHBOARD_LAYOUT__ at mount; if missing,
  best-effort fetch /api/me/dashboard-layout and re-render once data
  has landed. Factory order baked into dashboard.tsx is the fallback
  so a hydration failure never breaks the dashboard.

i18n: 5 new keys per language for the inbox widget. 2528 → 2533.

go build + go vet + go test ./internal/... -short + bun run build all
clean. Triple placeholder verified present in dist/dashboard.html.

Pixel-identical factory render budget: every previously-visible widget
keeps its DOM markup, classes, IDs, and parent. New widget (inbox-
approvals) lands between agenda and activity per the factory layout
ordering in WidgetCatalog. Visible regression on the factory layout is
+1 section (inbox-approvals), expected per m's Q3 pick.
2026-05-20 13:55:56 +02:00
mAi
15bcba5d7c feat(dashboard): t-paliad-219 Slice A3 — widen windows + add InboxSummary
Two changes to DashboardService for the configurable dashboard:

1) Widen upcoming windows from 7d/LIMIT 10 → 60d/LIMIT 40 for both
   loadUpcomingDeadlines and loadUpcomingAppointments. Per design §18
   Note B, the per-widget horizon dropdown (7/14/30/60 days) filters
   client-side from a single payload — server-side widening preserves
   the Q4 "one big payload" pick without forcing per-widget endpoints.
   Existing tests pass: the dashboard CTE bucket math is unchanged and
   the wider rows-list is a superset of what /api/dashboard returned
   before.

2) Add InboxSummary { pending_count, top: []InboxEntry } to DashboardData
   for the new inbox-approvals widget (Q3 expansion). Powered by
   ApprovalService.PendingCountForUser + ListPendingForApprover with
   Limit=InboxTopCap (10). InboxEntry is the minimum needed to render
   a clickable preview line: request id, entity_type/title, project,
   requester, requested_at.

   ApprovalService is wired post-construction via
   DashboardService.SetApprovalService to avoid a circular constructor
   dependency. When unwired (knowledge-platform-only deployments,
   tests), loadInboxSummary is a no-op and the widget renders its
   empty state.

3 new pure-function tests: nil-approvals no-op, SetApprovalService
wiring, InboxTopCap sanity.

go build + go vet + go test ./internal/... -short all clean.
2026-05-20 13:55:56 +02:00
mAi
48f78a713b feat(dashboard): t-paliad-219 Slice A2 — HTTP handlers + service wiring
Four endpoints for the per-user dashboard layout:

- GET  /api/me/dashboard-layout         (auto-seeds factory on first call)
- PUT  /api/me/dashboard-layout         (validates against catalog)
- POST /api/me/dashboard-layout/reset   (overwrites with factory default)
- GET  /api/dashboard-widget-catalog    (catalog metadata for the picker)

Catalog endpoint is DB-independent by design — knowledge-platform-only
deployments (no DATABASE_URL) still surface the widget metadata. The
layout endpoints 503 when the service is unwired, matching the pattern
established by handleListCardLayouts / handleListPinnedProjects.

Wired through services.Services → handlers.dbServices via the
DashboardLayout field. main.go gains a single NewDashboardLayoutService
call next to NewCardLayoutService.

ErrInvalidInput from the service maps to 400; everything else flows
through writeServiceError for the existing 500/503 fallthrough.

go build + go vet + go test ./internal/services/ -short all clean.
2026-05-20 13:55:56 +02:00
mAi
a421bff856 feat(dashboard): t-paliad-219 Slice A1 — user_dashboard_layouts storage + service
Migration 109 + DashboardLayoutSpec + Service + WidgetCatalog. No HTTP
handlers and no frontend yet — those land in A2/A3/A4 as separate commits
for cleaner review.

Why slot 109 (not 107 from the design doc): leibniz claimed 107 for
caldav_sync_log.binding_id and 108 for caldav_mkcalendar_capability after
the design was filed. Boltzmann's gap-tolerant runner (c85c382) lets any
embedded migration apply regardless of authoring order.

What ships:

- paliad.user_dashboard_layouts table: single-row PK on user_id (Q2 pick
  was single layout per user — no named-layout switcher). RLS owner-only,
  mirrors user_card_layouts / user_views patterns.
- DashboardLayoutSpec: { v: 1, widgets: [{ key, visible, settings? }] }.
  Validation is strict on write (catalog membership + per-widget settings
  schema, duplicate-key check, 32-widget cap, version pin). SanitizeForRead
  is forgiving — unknown keys dropped silently per design §10 versioning
  rule.
- DashboardLayoutService: GetOrSeed (auto-seeds factory default on first
  call, idempotent under concurrent first-load via ON CONFLICT DO NOTHING),
  Update (validates + upserts), ResetToDefault.
- WidgetCatalog: 7 v1 widget defs (deadline-summary, matter-summary,
  upcoming-deadlines, upcoming-appointments, inline-agenda, recent-activity,
  inbox-approvals). Per-widget WidgetSettingsSchema with CountOptions +
  HorizonOptions per design §18 Note B. pinned-projects const reserved
  but omitted from KnownWidgetKeys until Slice C lands its widget module.
- 18 pure-function tests pin: factory layout shape, validation failures
  (wrong version / over cap / unknown key / duplicate / bad settings),
  sanitize-on-read (drop unknown / noop on clean / bump version), JSON
  round-trip, catalog completeness, nil-schema behaviour.
- 4 live-DB tests (skipped without TEST_DATABASE_URL): GetOrSeed
  auto-seeds + idempotent, Update round-trips, Update rejects invalid,
  ResetToDefault overwrites.

Migration SQL dry-run live in BEGIN..ROLLBACK against supabase — clean.
go build + go test ./internal/services/ -short both clean.

Slice C0 (pin-machinery) from the design doc is OBSOLETE — paliad
.user_pinned_projects + PinService already exist (pre-dates t-paliad-219).
Slice C in the original plan becomes a single PR adding the
pinned-projects widget module that reads from the existing service.

Design: docs/design-dashboard-configurable-2026-05-20.md §5 + §18.
2026-05-20 13:55:56 +02:00
mAi
fbd087e0cd feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.

Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
  unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
  by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.

CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
  MKCALENDAR, falls back to a synthetic MKCALENDAR against a
  random .paliad-probe-XX/ path (with DELETE cleanup) to catch
  legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
  supported-components; returns ErrCalendarNameTaken on 405 so
  the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.

Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
  /api/caldav-discover call after credential change; result persisted
  via UPDATE on user_caldav_config. DiscoverCalendars response now
  carries supports_mkcalendar so the UI can show / hide the create-new
  radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
  via the client (with 3-try -XX-suffix retry on name collision),
  creates the matching binding, kicks off PushBindingNow. Returns
  the partial result on push failure so the UI can show "created but
  initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
  re-configured server gets re-probed on next open.

HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
  include_personal?} → 201 {calendar_path, binding, initial_pushed}.
  Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
  upstream. Partial-success (binding created, push failed) carries
  initial_sync_error in the body so the UI can surface both bits.

Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
  wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
  Create radio is visible only when supports_mkcalendar=true;
  when false, the bilingual Google-degrade notice is shown
  beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
  /api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
  + caldav.bindings.error.create_*.

Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
  no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
  bun run build all clean.

Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
2026-05-20 13:26:23 +02:00
mAi
1fcfab7791 feat(caldav): Slice 2b write APIs + picker UI (t-paliad-212)
User-visible Slice 2 milestone: the /einstellungen/caldav Kalender
section now lets a user pin multiple calendars to Paliad via a
single-step add modal (Q3 of the Slice 2 brief). m greenlit
"all yes / all R" on 2026-05-20, so this lands with: synchronous
first-push on POST (Q5), lazy cleanup on PATCH scope change (Q6),
5-minute server-side cache on /api/caldav-discover (Q4),
calendar_path retained-but-deprecated (Q7).

Backend
- CalDAVService.PushBindingNow — runs one push pass for a single
  binding synchronously; called from POST /api/caldav-bindings so
  the modal closes with events already landed.
- CalDAVService.RemoveBinding — best-effort remote-event DELETE +
  binding row drop (§2.6 of brief). On partial remote failure,
  the binding is disabled instead of dropped and the handler
  surfaces 202 Accepted.
- CalDAVService.EnsureLoop — spawns the per-user sync goroutine
  for users who didn't have one before this request.
- CalDAVService.DiscoverCalendars — walks current-user-principal
  → calendar-home-set → child PROPFIND (RFC 6764 §6 / RFC 6638
  §10). Cached 5 minutes per user; invalidated on SaveConfig /
  DeleteConfig.
- caldav_client.go gains DiscoverCalendars + propfindHrefs +
  listCalendars + supporting multistatus types. VEVENT-only
  filter skips iCloud reminder lists / addr books.

HTTP API
- POST /api/caldav-bindings — create binding + sync first-push;
  201 with binding + initial_pushed count, or 201 with
  initial_sync_error when the push fails after binding creation.
- PATCH /api/caldav-bindings/{id} — partial update.
- DELETE /api/caldav-bindings/{id} — calls RemoveBinding;
  responds 204 (full cleanup) or 202 (partial — binding disabled
  for next-tick retry).
- GET /api/caldav-discover — returns {calendars, calendar_home}
  for the picker.

Frontend
- /einstellungen/caldav Kalender section: list of binding cards
  with enabled toggle / Edit / Remove. "+ Kalender hinzufügen"
  opens the single-step modal.
- Single-step add modal: source picker (discovery dropdown or
  custom URL toggle) + scope radio (all_visible / personal_only
  / project + project picker) + display name. Edit mode reuses
  the modal with the source field hidden.
- 32 new i18n keys under caldav.bindings.* (DE primary, EN
  parallel) covering modal copy, card actions, error messages,
  delete-confirm, scope labels.

Verification
- Live Supabase BEGIN..ROLLBACK: full CRUD flow exercised
  (create → patch display_name → patch scope → second
  all_visible after the first scope-shifts → delete);
  the partial unique index frees correctly when scope moves
  off all_visible, no race or constraint surprise.
- go build ./... + go test ./internal/... + bun run build all
  clean.
2026-05-20 13:18:00 +02:00
mAi
f0b08e9d06 feat(approvals): t-paliad-217 Slice B — counter_payload allowlist expansion
m's t-paliad-217 Q1 lock-in (2026-05-20): the suggest-changes modal lets
the approver edit EVERY field on the underlying deadline / appointment,
not just the date allowlist that triggers approval. Server-side support
for the wider counter shape:

  - buildCounterSetClauses (new) — the counter-allowlist:
      deadline:    title, due_date, original_due_date, warning_date,
                   description, notes, rule_code (event_type_ids handled
                   separately via junction-table rewrite).
      appointment: title, start_at, end_at, description, location,
                   appointment_type.
  - buildRevertSetClauses (existing) stays narrow — Reject only restores
    what pre_image actually contains (defence-in-depth: a hostile UPDATE
    on the request row must not let arbitrary fields be reverted, and
    pre_image is server-written so what's in there is what we trust).
  - rewriteDeadlineEventTypes — junction-table DELETE+INSERT for the
    deadline_event_types m-to-m when counter_payload carries
    event_type_ids. Runs in the same tx as the entity UPDATE.
  - applyEntityUpdate — switched from buildRevertSetClauses to
    buildCounterSetClauses; gained the event_type_ids branch for
    deadlines.
  - SuggestChanges no-op validator — now uses buildCounterSetClauses
    so the wider field set counts as "differs".
  - title is treated as NOT NULL — whitespace-only counter title
    surfaces ErrSuggestionRequiresChange (defence-in-depth against the
    column's own NOT NULL CHECK).

Tests:
  - TestApprovalService_SuggestChanges_TitleOnlyCounter — title diff
    succeeds; entity title updates.
  - TestApprovalService_SuggestChanges_NotesOnlyCounter — notes diff
    succeeds; entity notes column populates.
  - TestApprovalService_SuggestChanges_EmptyTitleRejected — whitespace-
    only title rejected with ErrSuggestionRequiresChange.

No DB migration needed (counter_payload jsonb already accepts arbitrary
shape; the change is in the column-allowlist switch on read).
2026-05-20 13:05:59 +02:00
mAi
694c7a53ad feat(caldav): Slice 2a backend cut-over — bindings-driven sync (t-paliad-212)
Cuts the CalDAVService sync engine over from the Phase F scalar
calendar_path to the binding-row model introduced in Slice 1
(mig 101). Invisible-but-shippable: existing Phase F users keep
their backfilled all_visible binding, new users hitting the legacy
PUT /api/caldav-config get an auto-created all_visible binding so
the "configure → it just works" UX survives. Slice 2b adds the
picker UI and write APIs on top.

Schema (mig 107)
- paliad.caldav_sync_log.binding_id (nullable, FK ON DELETE SET NULL
  so audit history survives binding deletes).
- Per-binding index for the read path.
- Idempotent (column-exists DO block) + assertion.

Services
- CalendarBindingService: ListForUser, ListEnabled, ListAllEnabled,
  Get, Create, Update, Delete, SetSyncStatus. Mirrors the table
  CHECK constraints client-side so the API returns useful 400s.
- AppointmentTargetService: UpsertAfterPush, FindByUIDAndBinding,
  ListForBinding, DeleteByAppointmentAndBinding, StaleForBinding.
  Replaces SetCalDAVMeta as the authoritative source of per-target
  state; legacy scalar columns still written for back-compat.
- AppointmentService.ForBinding: scope filter implementing
  all_visible, personal_only, project. Hierarchy scopes
  (client/litigation/patent/case) return ErrUnsupportedScope —
  Slice 3 wires them via the existing path-based descendant
  predicate.

Sync engine rewrite
- CalDAVService.Start iterates ListAllEnabled to discover users
  with at least one enabled binding.
- runSyncOnce loops bindings, writes one caldav_sync_log row per
  (user, binding) tick, rolls the worst-case error up onto
  user_caldav_config.last_sync_error so /api/caldav-config still
  shows aggregate status.
- pushBinding pushes the ForBinding() slice + cleans up
  stale-target rows (project unshared, scope PATCHed).
- pullBinding swaps the N×GET pattern for REPORT calendar-multiget
  (RFC 4791 §7.9; chunked at 100 hrefs to stay inside provider rate
  limits) and reconciles via per-target etag comparison.
- Hooks (OnAppointmentCreated/Updated/Deleted) fan out across the
  user's matching bindings using appointmentInBinding() — best
  effort per binding, same 30s timeout as Phase F.
- SaveConfig auto-creates an all_visible binding on first-time
  configure so Phase F "configure → events appear" survives the
  cut-over.

CalDAV client
- New ReportMultiget verb implementing RFC 4791 §7.9
  calendar-multiget. Chunked at multigetMaxHrefs=100 to fit Google
  Calendar's per-request cap.

HTTP API
- GET /api/caldav-bindings — read-only list of the authenticated
  user's bindings. Slice 2b adds POST/PATCH/DELETE.

Verification
- BEGIN..ROLLBACK against live Supabase (PG 15.8): mig 107 applies
  cleanly + the synthetic two-binding scenario lands the project
  appointment in both bindings while keeping the personal one in
  master only; cascade on appointment-delete drops targets; cascade
  on binding-delete drops targets AND sets sync_log.binding_id NULL.
- go build ./..., go test ./internal/..., bun run build all clean.

Backwards-compat
- paliad.appointments.caldav_uid / caldav_etag still written in
  pushBinding so legacy readers see fresh values. Slice 4 drops
  them after telemetry confirms no path still reads them.
2026-05-20 13:05:27 +02:00
mAi
8f1f88b517 feat(export): t-paliad-214 Slice 2 backend — project-subtree sync export
Adds GET /api/projects/{id}/export?direct_only=0|1 streaming a
deterministic project-subtree bundle in the same xlsx + JSON + per-sheet
CSV shape as Slice 1's personal export. 16 entity sheets per design §2:
projects + project_teams + project_partner_units + deadlines +
appointments + parties + notes (4-way polymorphism resolved) + documents
(metadata only) + project_events + approval_requests + approval_policies
(triple-source attribution with `source` column for Q4 lock-in) +
checklist_instances + partner_units (attached only) +
partner_unit_members (members of attached units only) + users_referenced
(FK-referenced users only) + system_audit_log_subset. Personal sidecars
explicitly excluded; reference sheets (proceeding_types, event_types,
deadline_rules, courts, …) ship for standalone interpretability.

§4 permission gate enforced server-side:
  - global_admin can export anything, OR
  - direct project_teams membership with responsibility ∈ {lead, member}
  - Observers + Externals + derived-only partner-unit users → 403
    bilingual ("Datenexport ist nur Team-Mitgliedern (Lead / Member)
    vorbehalten / Data export is restricted to project team members").

Cross-subtree FK detection (Q3 lock-in: keep + warn) runs one
lightweight SELECT against projects.counterclaim_of and appends one
warning row to __meta.warnings per outbound reference. Recipients can
choose to keep or strip the FK on re-import.

Filename includes 8-hex-char short-uuid disambiguator (Q5 lock-in):
paliad-export-project-<slug>-<short-uuid>-<ts>.zip — two projects with
identical titles produce different filenames even when archived
together.

Audit row in paliad.system_audit_log (no new migration — already
supports scope='project'): metadata carries root_label + root_path
(ltree) + direct_only flag (Q6 lock-in) so the audit row remains
interpretable after the project is deleted.

__meta sheet + README.txt extended to surface project-scope fields:
scope_root_label, scope_root_path, direct_only.

ExportFilename signature extended to take a rootID; Slice 1 callsite
updated to pass uuid.Nil.

8 new pure-function tests pin: sheet registry shape (24 sheets in
order), triple-source approval_policies SQL tags, direct_only narrows
subtree to root-only, no-personal-sidecars guard, attached-only
partner_units filter, shortUUIDSuffix shape, project-scope meta rows,
short-uuid filename collision avoidance.
2026-05-20 13:03:57 +02:00
mAi
1765d5e55f feat(submissions): t-paliad-215 Slice 2 — patent_number_upc helper
UPC briefs parenthesise the patent kind code ("EP 1 234 567 (B1)")
where the DE convention runs it inline ("EP 1 234 567 B1"). Slice 2
adds the {{project.patent_number_upc}} placeholder for the new UPC
templates (Q-S2-4 locked at 'all yes' on 2026-05-20).

Pure function alongside legalSourcePretty. Trailing single-letter +
single-digit kind code regex; everything else preserved. Pass-through
on unrecognised shapes — the lawyer's draft never sees a number worse
than the source value.

Wired into addProjectVars so every render exposes both forms
({{project.patent_number}} and {{project.patent_number_upc}}). UPC
templates pull the parenthesised form; DE templates ignore it.

8 test cases (more than the 6 in the brief) covering:
- EP B1 / EP A1 — common case
- DE national with kind code
- No kind code → pass-through
- Whitespace trimming
- Empty input
- WO publication number (no kind-code shape) → pass-through
- Two-digit kind code (B12) → pass-through (intentional — real EP
  kind codes are single-letter + single-digit)

No schema change, no migration, no var-bag namespace additions
beyond the one new placeholder.
2026-05-20 12:59:40 +02:00
mAi
7a359989a9 feat(db): t-paliad-218 — gap-tolerant migration runner with applied-set tracker
Replaces the golang-migrate single-counter tracker with a hand-rolled
runner over embed.FS that tracks applied state as a set in
paliad.applied_migrations (version PK, name, applied_at, checksum).

Closes the parallel-merge skip-hole the 2026-05-20 mig-103 incident
exposed (m/paliad#44): a migration whose version is missing from
applied_migrations runs on the next deploy regardless of which higher
versions are already applied. Gaps are first-class.

Slice 1 of the design at docs/design-migration-runner-applied-set-2026-05-20.md.
All eight design decisions m-picked = inventor recommendation.

Runner contract:
- Ensure paliad schema → pg_advisory_lock(hash('paliad.applied_migrations'))
  → CREATE TABLE IF NOT EXISTS applied_migrations.
- bootstrapFromLegacyTracker: if applied_migrations is empty and the legacy
  paliad.paliad_schema_migrations row is present and clean, INSERT rows
  1..N for every on-disk version with checksum=NULL via ON CONFLICT DO
  NOTHING. Hard-fail if legacy tracker is dirty (operator must recover).
- scanEmbeddedMigrations: hard-fail on two .up.sql files sharing a version
  prefix — the failure mode the post-mortem exposed.
- checkNameAgreement: hard-fail on rename-after-apply mismatch (disk name
  for an already-applied version != DB name).
- applyOne: SQL body + INSERT(version, name, now(), sha256(file_bytes))
  in one transaction. All-or-nothing per migration.

Checksums populated on apply for future drift detection; rows backfilled
from the legacy tracker carry NULL (we can't fabricate a hash for what
golang-migrate applied historically). Verify-on-deploy intentionally
deferred to a focused follow-up — single if-block flip when m wants it.

Up-only runner. .down.sql files stay in embed.FS as reference; manual
roll-back path is psql + DELETE FROM paliad.applied_migrations WHERE
version=N. Zero call sites for migrate.Down in the codebase today.

Drops github.com/golang-migrate/migrate/v4 from go.mod (no other
importers; verified via grep).

Tests:
- internal/db/migrate_test.go: TestMigrations_DryRun walks pending =
  on_disk \\ applied (read from paliad.applied_migrations, missing-table
  → empty set), runs each in BEGIN/ROLLBACK against the scratch DB.
- cmd/server/main_smoke_test.go: TestBootSmoke asserts the applied set
  equals the on-disk set exactly (not just max-version-match) — catches
  the skip class the post-mortem documented. Dirty-flag check removed
  (rows are committed or absent, not 'dirty').
- All 45 service-test call sites of db.ApplyMigrations work unchanged
  (same signature, same fresh-DB behavior).

Follow-up: mig 108_drop_legacy_trackers (DROP paliad.paliad_schema_migrations
and public.paliad_schema_migrations) after one or two deploys of burn-in
on this slice.
2026-05-20 12:59:16 +02:00
mAi
0263a0e932 feat(approvals): t-paliad-216 — server-side hydration for back-link
Server-side additions so /inbox can render the suggest-changes back-link
without an extra client round-trip:

  - ApprovalRequestView gains NextRequestID. Hydrated via correlated
    subquery on previous_request_id; mig 103's partial index makes the
    lookup O(1) per row.
  - view_service.go approvalRowSubtitle picks up the changes_requested
    case ("Abgelehnt mit Vorschlag von <decider>").
  - filter_spec.go validRequestStatuses includes "changes_requested" so
    user-views can filter on it.
  - handlers/approvals.go isValidInboxStatus accepts "changes_requested"
    on the /api/inbox/{mine,pending-mine}?status= query. Test case added
    to TestParseInboxFilter_DropsUnknownStatus.
2026-05-20 10:02:36 +02:00
mAi
6401a8198d feat(offices): add Madrid as a firm office (mig 106)
m's ask 2026-05-20 09:42. Eighth HLC office alongside Munich,
Düsseldorf, Hamburg, Amsterdam, London, Paris, Milan.

- `internal/offices/offices.go` — append Madrid to All[] (display
  order: end of list, after Milan). Doc comment refreshed to point at
  the actual current CHECK constraints (users mig 002 + partner_units
  mig 018/024/027), not the obsolete akten reference from before
  projects-v2.
- `internal/offices/offices_test.go` — add `madrid` to the valid-keys
  table.
- mig 106 — extend the two CHECK constraints on users.office and
  partner_units.office. Idempotent (DROP IF EXISTS), audit_reason
  set_config at top, dry-run validated against the live youpc paliad
  schema (BEGIN; ALTER...; ROLLBACK).

Frontend picks up Madrid automatically via GET /api/offices.

Admin UI for managing firm office list is a separate longer-term
issue — m's "for now, just add Madrid already" path.
2026-05-20 09:52:28 +02:00
mAi
d924ab9743 test(approvals): t-paliad-216 SuggestChanges service + handler error mapping
Service-level (real DB, gated on TEST_DATABASE_URL like the rest of the
approval suite):
  - HappyPath: OLD row → changes_requested; NEW row pending with
    previous_request_id back-pointer; entity reflects counter payload;
    two project_events emitted (changes_suggested + requested).
  - NoOpRejected: identical counter + empty note → ErrSuggestionRequiresChange.
  - NoteOnlyAccepted: identical counter + non-empty note succeeds; entity
    keeps the original counter values.
  - SelfApprovalBlocked: original requester cannot suggest on their own row.
  - RequestNotPending: already-decided row rejects suggest-changes.
  - LifecycleInvalid: create-lifecycle pending → ErrSuggestionLifecycleInvalid.
  - OriginalRequesterCanApproveCounter: m's Q6 model — after the approver
    suggests changes, the ORIGINAL REQUESTER (now no longer the new row's
    requested_by) can approve the counter themselves provided their
    profession qualifies.
  - CounterApproverCannotSelfApprove: 4-Augen still holds — the suggesting
    approver cannot approve their own counter (ErrSelfApproval on the new row).

Handler-level (pure-Go, no DB):
  - SuggestionRequiresChange400: error code mapping.
  - SuggestionLifecycleInvalid400: error code mapping.
2026-05-20 09:50:07 +02:00
mAi
fb2896c836 feat(approvals): t-paliad-216 POST /api/approval-requests/{id}/suggest-changes
Wires the HTTP handler for the new action. Body shape:

    {"counter_payload": { ...allowlist fields... }, "note": "..."}

Returns 200 {"status": "ok", "new_request_id": "<uuid>"} on success.

Error mapping (via mapApprovalError):
    400 suggestion_requires_change   — ErrSuggestionRequiresChange
    400 suggestion_lifecycle_invalid — ErrSuggestionLifecycleInvalid
    403 self_approval_blocked        — ErrSelfApproval
    403 not_authorized               — ErrNotApprover
    404                              — not visible / not found (service)
    409 request_not_pending          — ErrRequestNotPending
    409 no_qualified_approver        — ErrNoQualifiedApprover

Route registered alongside the existing approve / reject / revoke trio
in handlers.go.
2026-05-20 09:50:07 +02:00
mAi
705e1a2e79 feat(approvals): t-paliad-216 SuggestChanges service method
ApprovalService.SuggestChanges is the fourth approval action — in one
transaction:

  1. Validates the OLD pending row (caller satisfies canApprove,
     lifecycle in update/complete only, counter differs from old.payload
     OR note is non-empty).
  2. Closes the OLD row as 'changes_requested' with decision_note +
     counter_payload + decided_by + decided_at + decision_kind.
  3. Reverts the entity from old.pre_image (reuses applyRevert — same
     code path Reject runs).
  4. Runs the deadlock check for the NEW row (excluding the suggesting
     caller; original requester is no longer excluded).
  5. Re-applies the counter_payload to the entity row (via
     applyEntityUpdate, mirroring the write-then-approve write).
  6. INSERTs a NEW pending approval_requests row authored by the caller
     with previous_request_id pointing back at the OLD row.
  7. Marks the entity pending + pending_request_id → new row.
  8. Emits two project_events: *_approval_changes_suggested + a fresh
     *_approval_requested for the new row.

4-Augen still holds: the suggesting caller is the new row's
requested_by, so self-approval on the new row is blocked by the standard
3-layer guard. The ORIGINAL requester is no longer the requested_by of
the new row — if their profession satisfies the required_role they can
now approve the counter themselves.

Adds:
  - const RequestStatusChangesRequested = "changes_requested"
  - var  ErrSuggestionRequiresChange   = "suggestion requires counter diff or note"
  - var  ErrSuggestionLifecycleInvalid = "suggest is only valid for update/complete"
  - models.ApprovalRequest.CounterPayload + PreviousRequestID
  - Per-row read paths (getRequestForUpdate, approvalRequestViewColumns)
    populate the new columns.
2026-05-20 09:50:07 +02:00
mAi
d8acbd613c feat(approvals): t-paliad-216 mig 103 — suggest-changes schema
Adds the schema scaffolding for the fourth approval action (alongside
Approve / Reject / Revoke):

  1. Extends approval_requests.status CHECK to include 'changes_requested'.
  2. Adds counter_payload jsonb — the approver's edited values on a
     changes_requested row (the basis of the new row's payload).
  3. Adds previous_request_id uuid FK — back-pointer from a SuggestChanges-
     spawned row to its source. Partial index on the FK supports chain
     traversal.

Non-blocking: extending a CHECK constraint is metadata-only on Postgres;
adding NULLable columns + a NULLable FK is metadata-only. Safe for live
deploy.

Dry-run validated against the live youpc paliad schema via BEGIN/ROLLBACK
(migration tracker at 102 pre-apply; schema unchanged post-rollback).
2026-05-20 09:50:07 +02:00
mAi
d127c768f7 feat(t-paliad-207): mig 105 — track-aware sequence reshuffle for upc.inf.cfi (infringement → revocation → amendment)
m's ask 2026-05-18 18:08: 'the infringement parts (like Replik) should
show above the part for the revocation (Erwiderung Nichtigkeitswider-
klage)'. Three tracks (infringement / revocation / amendment) coexist
on upc.inf.cfi once with_ccr / with_amend are set. They share tied
calendar dates because R.29/R.30/R.32 all key off the SoD or its
descendants. Current sequence_orders (post-mig 100) interleave them
arbitrarily; user sees Erwiderung-zur-CCR before Replik even though
Replik is the infringement-side response to the same triggering event.

**Re-sequencing** keeps the existing soc=0, prelim=5, sod=10 head and
the interim=40 / oral=50 / decision=60 / cost_app=70 / appeal_spawn=80
tail untouched. The 10 reshuffled rules move into a track-aware
arrangement:

  10-19 infringement: sod=10, reply=12, rejoin=14
  20-29 revocation:   ccr=20, def_to_ccr=22, reply_def_ccr=24, rejoin_reply_ccr=26
  30-39 amendment:    app_to_amend=30, def_to_amend=32, reply_def_amd=34, rejoin_amd=36

Tied-date ordering after the reshuffle:
  D+3mo: sod(10), ccr(20)                            — SoD then its CCR
  D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
  D+7mo: reply_def_ccr(24), def_to_amend(32)         — rev → amd
  D+8mo: rejoin_reply_ccr(26), reply_def_amd(34)     — rev → amd

**Two-phase swap** — every reshuffled rule first parks at sequence
1000+number, then jumps to its final value. Prevents transient
sequence-collisions if Postgres evaluates UPDATEs in parallel within
the same statement. Each UPDATE is keyed by submission_code AND the
SOURCE sequence_order, so re-apply is a no-op.

audit_reason set_config at top per mig 099 hotfix pattern.

Renumbered from mig 102 → mig 105 to avoid collision with archimedes
system_audit_log mig 102 (merged between fermi's parked session and
now); follows mig 104 (Einspruch name + CCR priority).
2026-05-20 09:47:14 +02:00
mAi
dab06e068f fix(t-paliad-207): mig 104 — strip rule cite from Einspruch names + flip CCR priority informational→optional
Two corrections to mig 100's merged-state:

1. **CCR priority informational → optional**. m's correction
   2026-05-18 18:01. The fermi amend (e8d658a) flipping this didn't
   land — paliadin merged the pre-amend c10f8cf. The Nichtigkeits-
   widerklage is a substantive defensive choice, rendered unchecked
   in the save modal so user opts in if they want to track it.

2. **Strip rule-cite brackets from Einspruch names**. m's
   correction 2026-05-18 18:08. Every other rule name in the corpus
   carries the act-name without a parenthetical rule cite — the two
   Einspruch rules were outliers:
     upc.inf.cfi.prelim  'Einspruch (R. 19 VerfO)'             → 'Einspruch'
     upc.rev.cfi.prelim  'Einspruch (R. 19 i.V.m. R. 46 VerfO)' → 'Einspruch'
   plus EN equivalents. The legal_source / rule_code columns already
   carry the citation in the meta line, so the name stays clean.

Idempotent: priority UPDATE guarded on 'informational'; name UPDATEs
guarded on the current parenthetical-bearing values. audit_reason
set_config at top per mig 099 hotfix pattern.

Renumbered from mig 101 → mig 104 to avoid collision with leibniz
CalDAV mig 101 + archimedes system_audit_log mig 102 (both merged
between fermi's parked session and now); mig 103 reserved for hertz.
2026-05-20 09:47:14 +02:00
mAi
2c94420a4b feat(submissions): t-paliad-215 Slice 1 — HTTP layer + wiring
Two endpoints under /api/projects/{id}/:

  GET /submissions
       Lists the project's filing-type rules (event_type='filing',
       lifecycle_state='published') for the project's proceeding,
       each annotated with has_template via the registry's cheap
       SHA-only probe. Powers the SubmissionsPanel.

  GET /submissions/{code}/generate
       Renders the .docx and streams it back as an attachment with
       Content-Disposition: attachment; filename="…". Writes three
       audit records: paliad.system_audit_log (event_type=
       'submission.generated'), paliad.project_events (event_type=
       'submission_generated', surfaces in Verlauf / SmartTimeline),
       and paliad.documents (doc_type='generated_submission',
       file_path NULL — bytes are regenerable from inputs per m's
       Q3 pick, no server-side binary). All three writes use a 10s
       background context so the user still gets the download if
       audit insertion races a slow DB.

File naming follows §7 of the design:
  {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx with locale-
  aware rule.name and slash→underscore sanitisation on
  case_number. Empty case_number falls back to an 8-hex-char id from
  the project UUID.

Visibility: ProjectService.GetByID gates every request; 404 (not
403) on no-access to avoid project enumeration. No profession floor
— matches every other write surface in paliad.

Wired into handlers.Services + dbServices + cmd/server/main.go.
Singletons constructed once at boot; no per-request allocation. No
migration needed — paliad.documents has no CHECK on doc_type, so
'generated_submission' is purely additive.
2026-05-19 13:42:51 +02:00
mAi
3677c81fbe feat(submissions): t-paliad-215 Slice 1 — template registry + variable bag
TemplateRegistry (services/submission_templates.go) walks the
m-locked Q4 fallback chain — templates/{FIRM_NAME}/{code}.docx →
templates/_base/{code}.docx → templates/_base/{family}.docx →
templates/_base/_skeleton.docx — against the Gitea repo
HL/mWorkRepo. SHA-cache + 5-min refresh check, identical pattern to
internal/handlers/files.go's HL Patents Style proxy. Distinguishes
"no template" (chain fallthrough) from "Gitea down" so the handler
can render different UI for each.

SubmissionVarsService (services/submission_vars.go) assembles the
~30-placeholder bag from project + parties + rule + next-deadline +
user + firm + today. Locale-aware long-date forms (DE + EN) and a
legal_source pretty-printer that rewrites DE.ZPO.276.1 → "§ 276 Abs.
1 ZPO" / "Section 276(1) ZPO" for the prefixes the 254-rule corpus
uses today. Unknown prefixes pass through unchanged.

Visibility inherits from ProjectService.GetByID
(paliad.can_see_project) — unauthorised callers get the same
ErrNotVisible that every project surface returns.
2026-05-19 13:42:51 +02:00
mAi
8ea3509b98 feat(submissions): t-paliad-215 Slice 1 — in-house .docx render engine
Pure-Go {{path.dot.notation}} placeholder engine + unit tests
(t-paliad-215, design docs/design-submission-generator-2026-05-19.md
§6). Chosen over github.com/lukasjarosch/go-docx because that library
treats sibling placeholders inside one <w:t> run as nested and
refuses to replace them — patent submissions routinely carry multiple
placeholders per paragraph (party blocks especially), so the library
is a non-starter.

Two-pass strategy preserves run-level formatting on the common path:

 1. Pass 1: regex replace inside each <w:t>…</w:t> independently —
    no format loss for the 99% case where placeholders are intact.
 2. Pass 2: paragraph-level merge for paragraphs that still contain
    orphan "{{" or "}}" markers (Word fragmented the placeholder
    across runs).

Missing placeholders render [KEIN WERT: <key>] / [NO VALUE: <key>]
markers so the lawyer sees the gap in Word rather than getting a 400.

Tests cover: single-run, multi-per-run (the go-docx failure mode),
cross-run merge, missing-marker (DE+EN), XML escaping of special
chars, non-document zip entries preserved, placeholder regex
grammar.
2026-05-19 13:42:51 +02:00
mAi
f9ff7b93e8 fix(export): xlsx docProps + pane XML — Excel "repairs required" + wrong Modified date
m hit two bugs opening the Slice 1 export in Excel / Windows:

1. **Excel showed a "Repairs required" prompt** on open. Root cause:
   the SetPanes call passed only `{Freeze: true, YSplit: 1}` — the
   obvious-but-wrong shape. The resulting <pane> XML missed the
   `topLeftCell` and `activePane` attributes that Excel requires for
   a frozen-row pane (excelize's parser is permissive on re-read but
   Excel is strict). Fix: complete the Panes struct (TopLeftCell="A2",
   ActivePane="bottomLeft", Selection on bottomLeft) and surface
   SetPanes errors instead of `_ =`-ignoring them.

2. **Windows Explorer / Excel's File→Info showed Modified=2006-09-16
   ("xuri")** — excelize's hardcoded first-commit defaults. Root cause:
   buildXLSX never called SetDocProps so the canned defaults leaked.
   Fix: SetDocProps({Created, Modified} = meta.GeneratedAt;
   Creator = "Paliad (<firm>)"; Title/Description scoped per export).

3. **Bonus**: the outer-zip entry mtimes were stamped 2000-01-01 (the
   deterministic constant) so extracted files showed a Y2K Modified
   date in Explorer. Now stamped meta.GeneratedAt, which preserves
   determinism within an export (same row state + same GeneratedAt →
   same bytes, the actual m's-Q6 contract).

Also: set the active sheet to __meta (index 0) after sheet creation so
a future code path that adds/removes sheets can't leave an out-of-range
active-sheet index that would trip a separate "repairs required" path.

Regression tests in dump_export_test.go pin all three fixes by re-opening
the generated xlsx via excelize.OpenReader and asserting:
- docProps Created/Modified == meta.GeneratedAt (RFC 3339 UTC)
- docProps Creator contains "Paliad"
- xlsx bytes never contain "2006-09-16T00:00:00Z" or "<dc:creator>xuri</dc:creator>"
- sheet2/sheet3 raw XML carries topLeftCell + activePane + state=frozen
- outer-zip entries' Modified is within ±2s of GeneratedAt
- developer hatch: DUMP_EXPORT=1 writes /tmp/paliad-export-debug.{zip,xlsx}
  for opening in real Excel.
2026-05-19 13:05:54 +02:00
mAi
1639b3919a feat(handlers): serve /patentstyle/HL-Patents-Style.dotm as "HL Patents Style.dotm" via Content-Disposition
URL keeps the dashed name for cleanliness; the on-disk filename PA users land in their Downloads folder has the canonical spaces.
2026-05-19 13:05:28 +02:00
mAi
28c7215458 feat(export): t-paliad-214 Slice 1 backend — personal sync export endpoint + xlsx/json/csv writer
Adds GET /api/me/export streaming a deterministic .zip bundle of the
caller's RLS-visible projection (per design §2.3): projects, deadlines,
appointments, parties, notes, documents (metadata), audit events,
approval requests, checklist instances + personal sidecars (me row,
caldav config without ciphertext, views, pins, card layouts, paliadin
turns) + reference data (proceeding_types, event_types, deadline_rules,
courts, countries, holidays …) + restricted users_referenced sheet.

Bundle shape: paliad-export.xlsx + paliad-export.json + per-sheet
CSVs (UTF-8 BOM, RFC 4180) + README.txt + __meta.json. Outer zip is
byte-deterministic — sorted file list, fixed Modified time on every
entry, sorted JSON keys. Two runs at same row-state → identical bytes.

ExportService.WritePersonal owns the SQL recipe + column discovery
+ PII deny-regex (?i)secret|token|password|api[_-]?key|private[_-]?key
+ per-sheet DropColumns belt-and-braces (e.g. user_caldav_config
.password_encrypted explicitly dropped on top of the regex). Audit row
written to paliad.system_audit_log before the run, patched with
row_counts + file_size_bytes after.

Migration 102 creates paliad.system_audit_log (generic event_type +
actor_id/email + scope + scope_root + metadata jsonb). Idempotent
CREATE TABLE IF NOT EXISTS + indexes; RLS enabled with self-read +
admin-read policies. AuditService.ListEntries gains a 6th UNION branch
so the new table surfaces on /admin/audit-log.

excelize/v2 added to go.mod for xlsx generation.

Pure-function tests pin formatCellValue value-coercion, PII regex,
CSV quoting + BOM + umlaut survival, JSON shape, meta key order
stability, filename slugify, and byte-determinism of the bundle
assembly.

Design: docs/design-paliad-data-export-2026-05-19.md §7 Slice 1.
2026-05-19 12:51:52 +02:00
mAi
8a43aed100 feat(caldav): mig 101 — multi-calendar binding schema + backfill (t-paliad-212 Slice 1)
Schema-only landing for Slice 1 of the CalDAV multi-calendar design
(docs/design-caldav-multi-calendar-2026-05-19.md). Sync engine NOT
touched — Slice 2 wires the per-binding fan-out. After this migration:

- paliad.user_calendar_bindings — N bindings per user with scope_kind
  ∈ {all_visible, personal_only, project, client, litigation, patent,
  case}. Hierarchy scopes anchor scope_id at paliad.projects(id).
  Partial unique indexes enforce one binding per (user, scope_kind,
  scope_id) for hierarchical scopes and one per (user, scope_kind)
  for the scope-less roots. RLS mirrors user_caldav_config.
- paliad.appointment_caldav_targets — per-(appointment, binding) join
  carrying caldav_uid + caldav_etag. UID stays canonical per
  appointment so the same event in N cals shares one UID.
- Backfill — one all_visible binding per existing user_caldav_config
  row, one target row per appointment already pushed. Maps target to
  the creator's binding, matching today's Phase F semantics where the
  creator's goroutine owns the etag.

Legacy paliad.appointments.caldav_uid / caldav_etag columns are
untouched (kept as denormalised pointers through Slice 1+2; dropped
in Slice 4 after telemetry).

Dry-run verified against live Supabase (PG 15.8): synthetic config +
appointment backfill creates exactly 1 binding + 1 target; re-run is a
no-op; all CHECK + unique-index constraints enforce as designed; final
assertions pass with 0 missing rows.

Prod impact at landing: 0 rows in user_caldav_config and 0 appointments
with caldav_uid — backfill is a true no-op. Slice 1 ships invisible.
2026-05-19 12:44:27 +02:00
mAi
586ba29b86 feat(test): migration dry-run gate + boot smoke (Slice 1)
Slice 1 of docs/design-paliad-test-strategy-2026-05-19.md — the test
infrastructure that would have caught mig 098 (digit-regex) and mig 099
(missing audit_reason) before the deploy hit prod.

Three new files + one route addition:

- Makefile: `make verify-migrations` (alias `verify-mig`) runs the
  per-migration dry-run + boot smoke against TEST_DATABASE_URL. Fails
  fast with a clear error if TEST_DATABASE_URL is unset so CI can't
  silently pass a missing env var. `make test` and `make test-go`
  cover the rest of the short / full Go suites.

- internal/db/migrate_test.go (TestMigrations_DryRun): walks every
  pending *.up.sql in numeric order, applies each inside its own
  BEGIN..ROLLBACK transaction, fails on the first SQL error with the
  file name + Postgres error. "Pending" = greater than the scratch
  DB's current tracker version, so fresh-DB CI runs verify everything
  while developer scratch DBs only re-verify the new pending migration.
  Always non-destructive — the rollback runs even on success.

- cmd/server/main_smoke_test.go (TestBootSmoke): boots the apply path
  end-to-end, asserts (a) db.ApplyMigrations returns nil, (b) the
  tracker advanced to the highest *.up.sql version on disk with
  dirty=false, (c) GET /healthz on the registered mux returns 200.
  The dry-run catches per-migration syntax errors; this catches the
  apply+bind path the container actually runs.

- internal/handlers/handlers.go: adds a GET /healthz public route — a
  no-auth, no-DB liveness probe. Used by the boot smoke; also safe
  for any future orchestrator or uptime check.

Both live-DB tests gate on TEST_DATABASE_URL and skip cleanly without
it, matching the rest of paliad's live-DB test pattern.

Verification: go build ./... clean, go vet ./... clean,
go test -short ./internal/... ./cmd/... clean (all packages pass,
live-DB tests skip), bun run build clean (2436 i18n keys unchanged).

Per CLAUDE.md inventor → coder gate, NOT self-merged.
2026-05-19 12:41:15 +02:00
mAi
22cfdb909f feat(handlers): serve /patentstyle/ for HL Patents Style auto-update
Hosts the manifest + .dotm that the Word ribbon's Check-for-Updates button polls. paliad.msbls.de is the primary endpoint; hihlc.msbls.de mirrors it (hihlc/main b871ded). Files live in frontend/public/patentstyle/, copied into dist/ by the frontend build. Cache-Control: no-cache via noCacheAssets so version.json never serves stale after a release.
2026-05-18 21:00:46 +02:00
mAi
c10f8cff70 feat(t-paliad-207): mig 100 — make CCR filing visible in calc output when with_ccr is set
m's observation 2026-05-18 (interactive session): toggling "Mit Nichtig-
keitswiderklage" surfaces the response rules (def_to_ccr, reply, rejoin,
…) but the triggering event itself — the act of filing the CCR — is
invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement of
Defence with the same 3-month deadline, so the corpus author (mig 028)
skipped it. UX problem: users see consequences without the cause.

**New rule** `upc.inf.cfi.ccr`:
- parent: `upc.inf.cfi.soc` (root anchor, same as SoD)
- duration: 3 months (same as SoD — no separate deadline)
- party: defendant
- legal_source: `UPC.RoP.25.1`
- condition_expr: `{"flag":"with_ccr"}`
- priority: **`informational`** — renders as a notice card, no save
  action, no duplicate write into paliad.deadlines (the SoD's row
  already covers the calendar date).

**Sequence reshuffle** — inserting at sequence_order=11 pushes
def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
SoD → CCR → def_to_ccr → app_to_amend (cause before effect).

**Idempotency** — INSERT uses NOT EXISTS keyed on
(proceeding_type_id, submission_code, lifecycle_state='published');
UPDATEs are guarded by the source sequence_order so re-apply is a
no-op. audit_reason set via set_config('paliad.audit_reason', ...,
true) at the top per the mig 099 hotfix pattern.

Migration counter re-checked against origin/main + ls
internal/db/migrations/ | tail before picking 100 — per the friction
note from msg 2016.

Build hygiene: go build/vet clean; bun run build clean (no i18n
changes). Down.sql restores both sequence values + DELETEs the new
row. Branch: mai/fermi/interactive-session.
2026-05-18 17:46:08 +02:00
mAi
283c9e8f67 fix(mig 099): add missing audit_reason wrapper
Mig 099 (drop_with_po_flag) crash-looped paliad.de prod immediately
after deploy: the mig 079 trigger on paliad.deadline_rules raises
EXCEPTION 'audit reason required' on UPDATE when paliad.audit_reason
is unset. Original file (fermi, t-paliad-207) only had the UPDATE,
no set_config wrapper.

Patch: prepend the standard 'SELECT set_config(paliad.audit_reason,
...)' at the top so the trigger sees the reason. Same shape as every
other migration that mutates deadline_rules.

Manual recovery already applied via head MCP — UPDATE'd the 2 rows
with audit_reason set, marked tracker version=99 dirty=false,
force-restarted the container which booted clean. This commit aligns
the in-repo file with the recovered prod state. Idempotent: the
WHERE clause matches only rows that still carry with_po, so re-apply
is a no-op.
2026-05-18 17:33:01 +02:00
mAi
8bf1626997 fix(mig): renumber drop_with_po_flag 098 → 099 (number collision with submission_codes_prefix_and_rename) 2026-05-18 17:29:21 +02:00
mAi
7f49851abf fix(t-paliad-207): drop with_po flag — R.19 Einspruch is always available, not flag-gated (mig 098)
m's correction 2026-05-18: the R.19 Einspruch (preliminary objection)
should not be flag-gated. It's an always-available optional submission
the defendant can make once the SoC is served — same logic as the
appeal-spawn rules in t-paliad-203 F2.3 ("the appeal is always a
possibility"). Removing the gate makes the row a normal optional rule:
priority='optional' (unchanged, set by mig 095) gives the save-modal
the existing pre-uncheck behaviour without a separate checkbox.

**Migration 098** (idempotent): NULLs condition_expr on the two RoP.019.1
rows pinned by proceeding code (`upc.inf.cfi` + `upc.rev.cfi`). Re-apply
is a no-op via the WHERE clause matching the live shape. Live DB row
state will sync when Dokploy applies the migration on next deploy — no
raw prod-write this turn (lesson from the previous shift's friction note).

**Frontend cleanup** — removes the two flag rows added to
verfahrensablauf.tsx + fristenrechner.tsx in the parent t-paliad-207
commit (inf-po-flag-row, rev-po-flag-row), the readFlags()/calculate()
push branches, the syncFlagRows() show/hide entries, and the change
listeners. Drops the 4 i18n keys (deadlines.flag.inf_po + rev_po,
DE + EN). Bun build clean: 2417 keys (was 2419, -2 keys × 2 langs).

Branch: mai/fermi/interactive-session @ third commit on top of Path A.
2026-05-18 17:29:14 +02:00
mAi
4131d2e2a6 feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 17:29:14 +02:00
mAi
d507db22a7 fix(mig 098): exempt orphan rules from §6.2 NULL-check (proceeding_type_id IS NULL)
Recovery during the prod outage uncovered a second mig 098 bug: §6.2
assertion '0 NULL submission_code on active+published rows' counted
the 77 orphan rules (proceeding_type_id IS NULL, cross-cutting
Wiedereinsetzung / Schriftsatznachreichung pattern) and rejected the
migration. Patch: gate the NULL count on `proceeding_type_id IS NOT
NULL` so orphans pass through. Migration already applied to prod via
manual recovery with the same patched assertion; this commit aligns
the in-repo file with the deployed state.
2026-05-18 17:28:19 +02:00
mAi
a0a3ec32a3 fix(mig 098): relax submission_code shape regex to allow digits in suffix
Mig 098 (t-paliad-209, ohm) crash-looped paliad.de prod for ~2h: §6.1
assertion regex `^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$` rejects
EPA rule codes that carry the statutory rule number in the suffix —
e.g. `epa.opp.boa.r106`, `epa.grant.exa.r71_3`, `epa.opp.opd.r116`,
`epa.opp.opd.r79_further`, `epa.opp.boa.entsch2`, `epa.opp.boa.r116`.
Migration's UPDATE step succeeds against these rows; the transactional
assertion blows them up; rollback leaves the migration tracker dirty
at version 98 and the container refuses to start.

Patch: allow `[a-z_0-9]` per segment instead of `[a-z_]` in both the
SQL assertion (mig 098 §6.1) and the matching Go shape regex
(submission_codes_shape_test.go). Same change in both spots so the
runtime sanity test stays aligned with the SQL invariant.

Manual recovery already applied: forced
`paliad.paliad_schema_migrations.version` back to 97 with `dirty=false`
so the next deploy retries mig 098 from scratch against the patched
file. No data state changed (mig 098 ran inside a transaction and
fully rolled back — snapshot table, prefix UPDATE, and column rename
all reverted).

go build ./... clean. TestProceedingCodeShapeRegexStandalone green.
2026-05-18 16:52:38 +02:00
mAi
a18b825bee feat(t-paliad-207): Verfahrensablauf + Fristenrechner polish (jurisdiction prefix, trigger-event, flag rows, rule links, R.19 label)
Five intertwined fixes m surfaced in the interactive session:

1. **Jurisdiction prefix on the picked proceeding** — the collapsed
   summary chip and the result header now read "UPC Verletzungsverfahren"
   / "DE Verletzungsklage (LG)" instead of the bare proceeding name.
   Disambiguates the 4 redundancies in the corpus once the picker
   collapses. Driven by .proceeding-group[data-forum] which is already
   on every group.

2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
   line now shows the first event in the proceeding (e.g. Klageerhebung,
   Nichtigkeitsklage) instead of the proceeding name. Populated from
   the calc response (isRootEvent=true) on every render; em-dash
   placeholder while step 3 hasn't rendered yet. lang-change keeps it
   coherent.

3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
   stripped the with_ccr / with_amend / with_cci toggles when it lifted
   the shared renderer; they never came back. Lifted the 4 existing
   rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
   preliminary objection, mig 095) — same wiring + show/hide rules on
   both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
   (R.30 only with a CCR).

4. **Rule references → youpc.org/laws links** — new
   BuildLegalSourceURL(src) maps the structured legal_source code to
   the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
   39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
   bodies have no youpc home yet and render as plain display text —
   filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
   LegalSourceURL so deadlineCardHtml can render <a target="_blank"
   rel="noopener"> when the URL is set.

5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
   only (EN canonical UPC RoP term stays "Preliminary objection").
   Client-side change only — i18n + JSX fallbacks. The matching DB
   rename on the two rule-name rows folds into joule's broader mig 097
   (legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
   applied during the session is captured under that audit reason; the
   no-op when joule's mig re-applies is harmless.

Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
  fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)

Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.

Branch: mai/fermi/interactive-session. NOT self-merged.
2026-05-18 15:58:26 +02:00
mAi
7d275cac6b Merge: t-paliad-210 — mig 097 legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through) 2026-05-18 15:54:47 +02:00
mAi
21727bf1ca feat(db): mig 097 — legal-citation backfill (huygens HIGH/MED + m's FLAG walk-through)
t-paliad-210 / paliadin-head msg 2002 + 2006. Applies huygens's HIGH/MED
proposals from docs/proposals/legal-citation-backfill-2026-05-18.md
(commit 391be09) plus m's FLAG walk-through:

  § 1  Easy wins                — 6 rows (rule_code only).
  § 2  HIGH/MED proceeding-typed — 15 rows.
  § 3  HIGH/MED orphans         — 47 rows.
  § 4  FLAG-A dedup (clean only) — 1 canonical fill + 3 archives
                                  (Wiedereinsetzung §123-PatG twin,
                                  Berufungsschrift, Berufungsbegründung).
                                  Mängelbeseitigung 6× and Beginn-
                                  Hauptsache 2× DEFERRED pending m's call
                                  on distinct-context rule_codes[].
  § 5  FLAG-B court-scheduled    — 26 rows. RoP.111 / RoP.118 / § 285 ZPO
                                  / § 300 ZPO / § 47 PatG etc.
  § 6  FLAG-C/D rubber-stamp     — 5 rows. RoP.52 / RoP.235.1 / § 273 ZPO.
  § 7  FLAG-E service triggers   — 6 rows. § 317 ZPO / § 99 / 47 / 79 PatG
                                  / R. 111 EPÜ.
  § 8  FLAG-F combined-pleading  — 5 rows via rule_codes[] multi-cite.
  § 9  FLAG-G/H/I + RoP.271.b    — 13 rows. Patentänderung INF/REV split,
                                  H sub-paragraphs, RoP.069 by analogy,
                                  + RoP.271.b secondary cite on 5 UPC
                                  initial submissions.
  § 10 R.19 label rename         — defensive backstop for fermi's prod
                                  write (t-paliad-207 consolidated).
  § 11 RoP.49.1 → RoP.049.1      — padding normalization on rev.defence.

FLAG-J 3 rows (d124c95b / 002c2ba7 / 902cc5d5) left NULL for m's
/admin/rules pickup. 11 rows total stay NULL post-mig (3 FLAG-J + 8
deferred dedup).

Snapshot table paliad.deadline_rules_pre_097 preserves pre-mig state
including the distinct rule_codes[] on the deferred Mängelbeseitigung +
Beginn-Hauptsache sets.

Dry-run on supabase produced expected counts:
  null_count=11, old_outlier=0, new_padded=2

Idempotent: re-applying matches no rows. Audit-trail through mig 079
trigger via set_config(paliad.audit_reason, ..., true).
2026-05-18 15:39:03 +02:00
mAi
bc5b3557d0 feat(t-paliad-209): rename DeadlineRule.Code → SubmissionCode across Go layer
Workstream B Go sweep — matches mig 098. Every place the deadline-rules
service reads/writes the per-rule identifier now uses the new column
name and the new struct field. Distinct from rule_code (legal citation)
and from proceeding_types.code (the proceeding's 3-segment code).

Touch points:
- models.DeadlineRule.Code → SubmissionCode (db + json tags renamed
  in lockstep — JSON contract `submission_code` is the new shape).
- deadline_rule_service: ruleColumns SELECT list updated.
- rule_editor_service: CreateRuleInput.Code → SubmissionCode (json tag
  too), INSERT + CloneAsDraft SELECT updated.
- projection_service: lookupRuleByCode → lookupRuleBySubmissionCode
  (SQL WHERE clause + error message); every r.Code / parent.Code /
  rule.Code / first.Code / src.rule.Code read renamed.
- fristenrechner: r.Code / prev.Code / rule.Code reads renamed in
  Calculate (parent-anchor + override-key + computed-by-code map) and
  in CalculateRule's LocalCode emission; the proceeding-code+submission-
  code resolver query uses `submission_code = $2`.
- event_trigger_service / deadline_calculator: r.Code reads renamed.

UIDeadline.Code (the calculator's wire response) is unchanged — that
field is a separate API contract pointing at the same value; renaming
it would force every frontend deadline-renderer through a contract
break that isn't part of this workstream.

Test fixtures updated to the new SubmissionCode field name; live-DB
tests updated to the post-mig-098 prefixed values (`inf.sod` →
`upc.inf.cfi.sod` etc.). New submission_codes_shape_test asserts
every active+published row matches the 4+-segment proceeding-prefixed
shape (sibling of TestProceedingCodeShape; mirrors mig 098 §6.1).

go build ./... clean. go test ./internal/... green.
2026-05-18 15:06:04 +02:00
mAi
bd2c7a217e feat(t-paliad-209): mig 098 prefix submission codes + rename code → submission_code
m's 2026-05-18 call (workstream B): the paliad.deadline_rules.code field
is a SUBMISSION identifier (the filing/event within a proceeding), not
the legal-citation rule code (which lives in rule_code / legal_source).
Two cleanups land in this migration:

1. DATA — prefix every existing submission code with its proceeding
   code so submission codes carry the full hierarchical shape:
       inf.soc       (on upc.inf.cfi)  → upc.inf.cfi.soc
       de_inf.klage  (on de.inf.lg)    → de.inf.lg.klage
       de_inf_bgh.revision (on de.inf.bgh) → de.inf.bgh.revision
   Idempotent: WHERE NOT LIKE pt.code || '.%' skips already-prefixed
   rows so re-running is a no-op.

2. SCHEMA — rename paliad.deadline_rules.code → submission_code so
   future devs don't conflate it with rule_code (legal citation) or
   proceeding_types.code. The rename is guarded by a column-existence
   check, idempotent on a second run.

Drops + recreates the deadline_search materialized view because its
SELECT bakes `dr.code AS rule_local_code` (mig 051 §4); the rebuild
sources from `dr.submission_code` and reproduces every index from mig
051 verbatim.

Backup snapshot table paliad.deadline_rules_pre_098 captures the rows
before the prefix step; serves as the audit anchor and the down's
source.

Hard assertions (§6) gate the migration on:
- every active+published row matches the 4+-segment proceeding-prefixed
  shape regex
- no NULL submission_code on active+published rows
- the column was actually renamed
2026-05-18 15:05:46 +02:00
mAi
216abbfc98 feat(t-paliad-206): switch Go layer to lowercase dot-form proceeding codes
Sweeps internal/services + internal/handlers + internal/models to use
the new proceeding codes landed by mig 096. Stable Code* constants
live in internal/services/proceeding_mapping.go so a future rename
needs to touch one file.

Substantive changes:
- proceeding_mapping.go gains ResolveCounterclaimRouting() — the
  cascade resolver that routes upc.ccr.cfi (illustrative peer) back
  to upc.inf.cfi with with_ccr=true as default flag (design doc S1).
- deadline_search_service.go forum-bucket map updated; upc.ccr.cfi
  added to upc_cfi since it is a CFI peer.
- project_service.go CreateCounterclaim default lookup parameterised
  so the SQL string carries the constant, not a literal.
- proceeding_codes_shape_test.go: new file. Validates the shape
  regex standalone (always runs) and walks live DB rows asserting
  every active fristenrechner row matches the new shape + every
  stable Code* constant resolves to exactly one active row.

Comments and test fixtures throughout the Go tree updated to the
new shape. Tests pass under `go test ./internal/... -short`.
2026-05-18 12:13:24 +02:00
mAi
cce0ada3ce feat(t-paliad-206): mig 096 — rename proceeding_types.code to lowercase dot-form
19 active fristenrechner codes renamed from UPPER_SNAKE to the
lowercase three-position dot-separated taxonomy ratified by m on
2026-05-18 (see docs/design-proceeding-code-taxonomy-2026-05-18.md).
IDs are stable; only the `code` STRING changes.

Adds upc.ccr.cfi as an illustrative peer of upc.inf.cfi
(is_active=true, no rules — Go code routes cascade hits back to
inf.cfi with with_ccr=true).

Also updates the soft `proceeding_type_code` references on
paliad.event_category_concepts so the soft-join through
proceeding_types.code keeps resolving, refreshes the
deadline_search materialized view, and installs the
paliad_proceeding_code_shape CHECK constraint enforcing
`^[a-z]+\\.[a-z]+\\.[a-z]+$` on every active row.

Idempotent: every UPDATE is guarded on the OLD code; INSERT uses
WHERE NOT EXISTS; CHECK is dropped-then-recreated by name. Backup
snapshot lives in paliad.proceeding_types_pre_096. Dry-run on the
live youpc DB (BEGIN; … ROLLBACK) confirmed 20 active rows on the
new shape, 0 old codes left, 1 active upc.ccr.cfi.
2026-05-18 12:13:13 +02:00
mAi
af30c06d9b feat(t-paliad-205): mig 095 — ingest t-paliad-203 fristen gap-fill deltas
Codifies curie's 4 new rules + 4 patches from
docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3 (m's decisions).

NEW (4):
  inf.prelim         UPC_INF  parent=inf.soc      1mo  RoP.019.1  flag=with_po
  rev.prelim         UPC_REV  parent=rev.app      1mo  RoP.019.1  flag=with_po
  inf.appeal_spawn   UPC_INF  parent=inf.decision 2mo  RoP.220.1.a  always-fire  → UPC_APP
  rev.appeal_spawn   UPC_REV  parent=rev.decision 2mo  RoP.220.1.a  always-fire  → UPC_APP

PATCH (4):
  de_inf.klage       legal_source NULL → 'DE.ZPO.253'
  de_inf.anzeige     no change (already correct — explicit in audit log)
  de_inf.erwidg      is_court_set false → true + §276 Abs.1 S.2 description
  de_inf.berufung    defensive verify legal_source = 'DE.ZPO.517'

Idempotent via WHERE NOT EXISTS (no unique index on (proceeding_type_id,
code) — mig 093 left archived rows sharing codes with their published
successors, so ON CONFLICT isn't available). UPDATEs guarded by clauses
that only fire when the row still has the old value.

Backup snapshot in paliad.deadline_rules_pre_095 (CREATE TABLE IF NOT
EXISTS); down migration restores from it. Hard assertions verify all 4
new rules landed active+published, de_inf.erwidg flipped to court-set,
both spawn rules chain to a valid proceeding_type id=11.

Dry-run verified end-to-end against the live Supabase corpus inside
BEGIN/ROLLBACK; idempotency confirmed by running INSERT+UPDATE twice
in the same transaction.
2026-05-18 11:46:12 +02:00
mAi
aa82434af9 fix(t-paliad-202): grey out inbox actions instead of erroring on illegal click
m's UX bug (2026-05-17, paliad.de prod): clicking Genehmigen/Ablehnen/
Zurückziehen on a row the viewer can't act on alerted ("Eigengenehmigung
nicht zulässig.", "Sie haben nicht die erforderliche Rolle.") after the
POST round-trip. m's ask: "approval that i cannot grant should have the
'Genehmigen' button greyed out... that would be better than showing an
error when I try."

Backend (internal/services/approval_service.go):
- ApprovalRequestView gains viewer_can_approve + viewer_is_requester
  booleans. Resolved server-side per caller — false on self-authored rows
  (caller == requester), true when the eligibility predicate matches.
- Extract the eligibility EXISTS-block into approvalEligibilitySQL const
  and reuse it in ListPendingForApprover (WHERE), PendingCountForUser
  (WHERE), and the new viewer_can_approve SELECT expression. Single
  source of truth for the gate, identical to canApprove.
- ListPendingForApprover, ListSubmittedByUser, and GetRequest all bind
  $1 = callerID so the SELECT computes the flags inline (one query, no
  N+1). GetRequest's signature grows a callerID arg; the handler passes
  the authenticated user.

Frontend (frontend/src/client/views/shape-list.ts):
- ApprovalDetail picks up the two booleans (optional — falsy is safe:
  it disables, never falsely enables).
- approvalActionBtn renders the button as before but flips
  btn.disabled + sets a tooltip via disabledReasonFor: approve/reject
  share the viewer_can_approve gate (self → self_approval tooltip;
  unauthorized → not_authorized); revoke needs viewer_is_requester.
- All three buttons still render on every pending row so users see
  what's possible — the disabled+tooltip combo explains what's not.

i18n + CSS:
- 3 new keys × DE/EN: approvals.disabled.{self_approval,
  not_authorized,revoke_not_requester}.
- .inbox-row-action:disabled neutralises the .btn-primary/danger/
  secondary variant via opacity + not-allowed + muted tokens.

Tests:
- internal/services/approval_service_test.go::TestApprovalService_ViewerFlags
  is a 4-case table-driven live-DB test (skips without TEST_DATABASE_URL):
  self-authored (false/true), eligible peer (true/false), non-eligible
  viewer (false/false), global_admin (true/false). Also asserts the flags
  on ListPendingForApprover + ListSubmittedByUser rows.

Defence-in-depth preserved: server still rejects illegal POSTs with the
same error contract, and the alert path stays in inbox.ts for the race
where state changes between render and click.
2026-05-17 12:44:29 +02:00
mAi
bdd4999213 fix(projects): unbreak Create — drop $1::text reuse + tighten CM CHECK to 6 digits
Two issues m hit and reported in one breath while adding a project:

1. **Internal error on POST /projects** (prod-only, surfaced at 10:23). Both
   ProjectService.Create and CreateCounterclaim re-referenced the uuid
   parameter `$1` as `$1::text` to fill the path placeholder. Postgres'
   planner deduced conflicting types for `$1` (uuid in the id column,
   text in the cast) and rejected the prepared statement with 42P08
   "inconsistent types deduced for parameter". The path placeholder
   value is irrelevant — paliad.projects_sync_path() (BEFORE INSERT
   trigger from mig 018/021) always overwrites it from id and parent
   path. Fix: replace `$1::text` with a literal '' in both INSERTs,
   keeping the parameter list decoupled from the id column's type.
   Same comment now anchors the rationale on both call sites.

2. **CM number length — 6 digits, not 7.** m's correction; mig 018's
   `^[0-9]{7}$` CHECK on paliad.projects.client_number and
   matter_number was wrong. Mig 094 snapshots affected rows to
   paliad.projects_pre_094, NULL-s the 3 surviving 7-digit test
   values (2 client_numbers, 1 matter_number), then swaps the legacy
   `projekte_*_check` constraints from {7} to {6}. Frontend pattern,
   maxLength, placeholder, labels, and i18n hint flipped from 7 → 6
   on both DE and EN sides; format hint reads CCCCCC.MMMMMM now.

Dry-run against live DB (BEGIN..ROLLBACK):
- Fixed Create SQL: trigger populates path = id::text (36 chars). ✓
- Mig 094: 2 rows snapshotted, 0 clients/matters remain after clear,
  0 rows violate the new 6-digit CHECK. ✓

go build, go test ./internal/..., bun run build all clean.
2026-05-17 12:30:53 +02:00
mAi
40e49e87d4 refactor(t-paliad-200): Slice 9 follow-up B — retire litigation category from rule corpus
Lorenz's Slice 9 (t-paliad-195) deferred mig 093 because 40 active
paliad.deadline_rules still pointed at the 7 litigation-category
proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). Phase 3
Slice 5 (mig 087/088) already retired the category from project-binding;
this migration retires it from the rule corpus.

PLAN CHOICE (audit-gated, paliadin-approved): archive-all-40 rather than
the original re-parent plan. The audit found that 23 of 40 Pipeline-A
rules share their `code` with an existing fristenrechner rule on the
proposed re-parent target (e.g. inf.oral exists on both INF and
UPC_INF). Re-parenting would leave two rules with identical
(proceeding_type_id, code), breaking the implicit per-proceeding
rule_code identity contract keyed off by projection / search /
rule_editor. The fristenrechner rules are clearly the production
version (proper German names, legal_source pinned to UPC.RoP citations,
full bilateral chains, intra-proceeding counterclaim handling); the
Pipeline-A rules are stubs (English-only, mostly NULL legal_source,
duration_value=0 for 28 of 40, no spawn_proceeding_type_id wiring).

Migration 093 sequence (atomic):
  1. Snapshot proceeding_types_pre_093 + deadline_rules_pre_093 as
     permanent audit anchors.
  2. INSERT _archived_litigation pt (category='archived',
     is_active=false, jurisdiction='UPC') to home the rules.
  3. UPDATE all 40 rules → archive pt + lifecycle_state='archived' +
     is_active=false. Captured in paliad.deadline_rule_audit via the
     mig 079 trigger.
  4. DELETE the 7 litigation rows from paliad.proceeding_types (now
     safe — nothing references them).
  5. Hard assertions: 0 litigation rows survive, exactly 40 rules on
     the archive pt, every snapshot row matches a surviving rule by id.

Critical FK note: deadline_rules.proceeding_type_id is ON DELETE CASCADE
→ proceeding_types(id). A naive DELETE of the 7 litigation rows would
cascade-delete all 40 rules and break the FK from the 1 live deadline
("Lecker Frist", completed) that still references inf.rejoin/INF.
Re-homing the rules before deleting the pt rows is mandatory.

Verified via BEGIN..ROLLBACK against live DB: assertions pass, all 30
intra-litigation parent_id chains preserved, the live deadline FK
stays valid.

Test impact:
  internal/services/project_service_test.go:72 used to look up
  category='litigation' AND code='INF' to exercise the Slice 5 negative
  case. Post-mig-093 that lookup returns NULL. Rewritten to fetch any
  category <> 'fristenrechner' row (the _archived_litigation pt is the
  canonical post-093 row); defence-in-depth coverage of both the Go
  service guard and the mig 088 SQL trigger is preserved.

SURFACED FOR LEGAL REVIEW (4 coverage questions the audit found, to be
triaged as follow-up tasks):

  1. inf.prelim (Preliminary Objection, RoP 19, 1 month) — not present
     on UPC_INF. Possible coverage gap; legal review to decide whether
     to add it to the fristenrechner ruleset.
  2. inf.appeal / rev.appeal / ccr.appeal as cross-proceeding spawns
     into UPC_APP (2 months, UPC.RoP.220.1) — fristenrechner UPC_APP
     currently starts standalone with no spawn from UPC_INF/UPC_REV.
     Possible UX gap; Pipeline-A versions had
     spawn_proceeding_type_id=NULL so they weren't functional spawns
     either.
  3. ccr.amend / rev.amend (spawn rules) — superseded by
     inf.app_to_amend / rev.app_to_amend on UPC_INF / UPC_REV. Safe to
     drop; no action needed.
  4. zpo.klage / zpo.vertanz / zpo.klageerw / zpo.berufung — no UPC
     analogue; redundant with the DE_INF / DE_INF_OLG / DE_INF_BGH and
     DE_NULL / DE_NULL_BGH chains. Safe to drop; no action needed.

Files:
  internal/db/migrations/093_retire_litigation_category.up.sql   (new)
  internal/db/migrations/093_retire_litigation_category.down.sql (new)
  internal/services/project_service_test.go                      (test rewrite)
2026-05-16 01:29:31 +02:00
mAi
29a6b58747 refactor(t-paliad-199): Slice 9 follow-up A — drop legacy event_deadlines tables
EventDeadlineService.Calculate now reads source rows from
paliad.deadline_rules directly (WHERE trigger_event_id IS NOT NULL),
joining via UUID instead of title_de string. The legacy SELECTs against
paliad.event_deadlines + paliad.event_deadline_rule_codes are gone.

Migration 092:
- Snapshots both legacy tables into _pre_092 audit anchors.
- Adds paliad.deadline_rules.rule_codes text[] and backfills the 72
  multi-code citations from event_deadline_rule_codes via the
  sequence_order = 1000 + ed.id convention from mig 085 (70 of 77
  Pipeline-C deadlines carry codes; 7 are codeless).
- Hard assertion ties source-junction-row count to backfilled
  text[]-element count — any sequence_order mismatch aborts the drop.
- Drops the mig 086 read-only trigger (orphan once event_deadlines
  goes away).
- Drops paliad.event_deadlines + paliad.event_deadline_rule_codes.
- Final assertion: >=77 active deadline_rules with trigger_event_id
  NOT NULL — Slice 3 corpus must not have collapsed.
- audit_reason wrapper at top so the deadline_rules UPDATE row-trigger
  records the reason in deadline_rule_audit.

Verified via BEGIN..ROLLBACK against the live paliad DB: 72 codes
backfilled into 70 rule_codes arrays, multi-code rules (RoP.029.a +
RoP.030 for ed_id=6) preserve their ordering, composite rules
(combine_op=max) remain intact, both tables drop cleanly, all
assertions pass.

Parity test rebound to deadline_rules — independent computation still
re-runs applyDuration against raw column values for date/composite
parity. EventDeadlineResult.ID stays int64 via the sequence_order -
1000 convention so the public /api/tools/event-deadlines wire shape
is unchanged.
2026-05-16 01:17:23 +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
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