Four UX cleanups on /tools/fristenrechner per m's 2026-05-20 14:02–14:04
report:
1. **Pre-fill project on 'Add'** — when Step 1 binds an Akte, both the
Pathway A "Save to Project" modal and the Pathway B card-calc inline
'Add' picker now default their <select> to that project. Override
still allowed; the picker lists all projects. New helper
`preselectedProjectId()` reads `currentStep1Context` once so both
surfaces stay in sync.
2. **Drop 'Custom' prefix from UPC/DE/EPA/DPMA adhoc chips** — the
chip context already reads "oder ad-hoc, ohne Akte"; 'Custom' was
redundant signaling. Labels become "UPC-Verfahren" /
"UPC proceeding" (and the three sister jurisdictions).
3. **Remove 'Ich möchte etwas einreichen' from 'Was ist passiert?'** —
the Fristenrechner is a backward-looking calc ("event happened, what
spawns?"); the forward-workflow framing ("I want to file X") needs a
different tool. Filter the `ich-moechte-einreichen` root subtree out
in `loadEventCategoryTree()` (HIDDEN_CASCADE_ROOTS set) so the picker
never offers it. DB rows preserved for the future forward-workflow
tool, tracked in m/paliad#65.
4. **Same-context-asked-twice on Statement-of-Defence picker** —
when the user clicks a specific rule pill on a concept card, the
calc panel now renders a locked "Kontext: <proceeding — rule>"
caption with an "ändern" affordance instead of re-showing the same
five proceedings as a radio fieldset. When the user clicks the card
body (no specific pill), the picker is still the primary surface, but
the card's rule-pill section hides via CSS while expanded
(`fristen-card-pills-section--rules`) so the same options aren't
listed twice. Cross-cutting trigger pills (Wiedereinsetzung,
Weiterbehandlung etc.) stay visible — they're conceptually
different siblings, not the same proceeding context.
m's priority bug 2026-05-20: 'we cannot change the dates in that anymore!
the timeline dates — they seem to be fix, nothing happens when I click on a date.'
Regression introduced when the verfahrensablauf-core renderer was extracted
as the shared source of truth for both /tools/verfahrensablauf and
/tools/fristenrechner — the delegated click handler that opens the inline
date-edit modal was wired on the Fristenrechner side but never re-attached
on the Verfahrensablauf side. Anchor overrides + editable:true flag were
not threading through.
Fix: thread anchorOverrides + editable:true through CardOpts into the
shared renderer; wire the delegated click handler on
/tools/verfahrensablauf; pin the editable → data-rule-code contract with
5 regression tests so this can't re-break silently.
Per-rule due dates on /tools/verfahrensablauf were rendered as plain
spans with no `frist-date-edit` attrs and no delegated click handler,
so clicking a date did nothing (m's "the timeline dates seem to be fix,
nothing happens when I click on a date"). The wiring existed on
/tools/fristenrechner but had never been mirrored onto the abstract-
browse surface introduced in t-paliad-179.
Fix: lift the inline date editor + delegated click wiring out of
fristenrechner.ts into views/verfahrensablauf-core.ts so both pages
share one implementation:
- openInlineDateEditor(span, onCommit) — swaps the date span for
a `<input type=date>`, commits on blur/Enter, cancels on Escape,
fires `onCommit(ruleCode, newValue)` ("" = revert).
- wireDateEditClicks(container, onCommit) — idempotent delegated
click + keyboard handler that resolves `.frist-date-edit
[data-rule-code]` and opens the editor. Survives innerHTML
rewrites because the listener lives on the container.
verfahrensablauf.ts now:
- Owns its own anchorOverrides Map (cleared when proceeding-type
changes — overrides for one proceeding don't apply to another).
- Forwards overrides in calculateDeadlines() so downstream rules
re-anchor on the user's date.
- Passes `editable: true` to renderColumnsBody + renderTimelineBody.
- Calls wireDateEditClicks() once on #timeline-container in
DOMContentLoaded.
fristenrechner.ts shrinks: openInlineDateEditor + the inline click /
keydown blocks are replaced by an `onDateEditCommit` callback handed
to the shared wireDateEditClicks(). No behaviour change there.
Regression test: views/verfahrensablauf-core.test.ts pins the
editable→`data-rule-code` contract on `deadlineCardHtml` so a future
refactor that drops the attrs fails loudly instead of silently
breaking click-to-edit on both pages.
Slice A of the configurable user dashboard. Backend + factory layout served
on /dashboard; edit-mode + drag/drop come in Slice B.
- A1: paliad.user_dashboard_layouts storage (mig 109 — single-row-per-user
PK, jsonb layout, RLS owner-only) + UserDashboardService CRUD.
- A2: HTTP handlers + service wiring (GET/PUT /api/user/dashboard).
- A3: widened server windows for the baseline widgets (deadlines 7d→60d
LIMIT 10→40 with client-side filtering; activity similarly) +
InboxSummary aggregate so the new inbox-approvals widget has data.
- A4: frontend widget dispatch + the 7 v1 widgets (6 baseline + inbox-
approvals). 8th widget (pinned-projects) lands in Slice C, gated on the
Slice C0 pin-machinery pre-req per m's Q3 deviation in the design doc.
Mig 109 lands cleanly via boltzmann's gap-tolerant applied-set tracker.
Edit mode (Anpassen toggle + drag/drop + per-widget settings) is Slice B.
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.
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.
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.
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.
Completes the CalDAV multi-calendar product. Slice 2 (a + b + c) is now
shipped end-to-end.
- mig 108 — user_caldav_config.supports_mkcalendar (tri-state: NULL=unprobed,
TRUE=show create radio, FALSE=Google-degrade UX) + mkcalendar_probed_at.
Capability lives on the server-creds row per Q2 — capability is per-server.
- POST /api/caldav-mkcalendar — issues MKCALENDAR + creates matching binding
in one tx; 501 if probe=false; 409 on name conflict; 5xx upstream.
- caldav_client.go: OPTIONS probe (Allow: header parse) + synthetic
fallback (MKCALENDAR against /.paliad-probe-<rand>/ then DELETE) for
legacy SOGo / misconfigured Radicale that don't expose MKCALENDAR in
Allow. Probe runs once + caches.
- 'Create new calendar' radio in the add-modal — visible only when
supports_mkcalendar=TRUE. Slugifies display_name → calendar path with
-N collision retry; gives up after 3 with 'pick a name yourself' error.
- Google-degrade UX (probe=FALSE): create-button hidden, bilingual notice
surfaced, manual-URL input with PROPFIND-Depth-0 validation on submit,
NO OAuth bounce.
t-paliad-212 complete: Slice 1 (mig 101 schema + bootstrap binding) +
Slice 2a (sync engine cut-over + mig 107 binding_id) + Slice 2b (write
APIs + picker UI) + Slice 2c (this). Hierarchy scopes
(client/litigation/patent/case) remain parked for Slice 3 per the master
design.
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.
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.
m greenlit 6 picks in §0a (2 divergences from inventor recs: Q1 full-edit
loosens t-paliad-138 4-Augen policy beyond date changes; Q4 broadcast.ts
retrofit included in this PR for primitive validation).
Slice A — components/modal.ts primitive + global.css block. Native <dialog>
substrate (browser-owned top-layer, ESC, focus). openModal({title, body,
primary, secondary, size, onClose}) returns a Promise<T|null>. Backdrop
click closes via target check on dialog click event. History pushState on
open + popstate listener for browser back-button close on mobile. Focus
restoration to previously-focused element on close. Mobile full-screen
takeover with max-height excluding the PWA bottom-nav.
Slice B — counter_payload allowlist expansion in approval_service.go.
Renames buildRevertSetClauses → buildEntityFieldSetClauses; separates the
'revert from pre_image' allowlist (defence-in-depth for Reject) from the
'counter from approver' allowlist (wider, for SuggestChanges). New
editable fields for SuggestChanges: deadline {title, description, notes,
rule_code, event_type_ids — junction table writes}; appointment {title,
description, location, appointment_type}.
Slice C — approval-edit-modal full rewrite using openModal. Every field
in the requester's payload becomes editable; read-only context section
shows project / requester / created_at / approval status pill /
event-type chips (where not editable). Vorschlagskommentar prominent.
Submit disabled until form dirty OR note has content (mirrors
ErrSuggestionRequiresChange server-side guard).
Slice D — broadcast.ts retrofit onto the new primitive. Drops bespoke
.modal-broadcast CSS overrides + the per-modal ESC / close / backdrop
handlers. Demonstrates the primitive's generality.
Slice E (i18n + CSS cleanup) folded into Slice A's commit — all new keys
authored once. Legacy .modal-overlay / .modal-card / .modal-content / .modal
CSS retained for the other 7 unmigrated modals (each migrates in a
follow-up PR).
2489 i18n keys; data-i18n attributes clean. No DB migration.
m's Q4 lock-in (2026-05-20): retrofit the richest existing modal —
broadcast.ts (bulk team-email compose) — onto the unified primitive to
demonstrate its generality on a real-world surface.
Changes:
- Body is built imperatively (renderBody + wireBody) and handed to
openModal as the body element. The submit logic reads form state
from that element on primary-handler invocation.
- Drops the per-modal ESC + close + backdrop + overlay-stack handlers
— the primitive owns them.
- Drops the bespoke .modal-broadcast { width / max-height / padding /
label / input / textarea } CSS overrides. The primitive's data-size
handles width; the existing .form-field rules handle inputs; only
the textarea's code-monospace font is kept as a broadcast-specific
override (placeholder syntax needs to read as code).
- Primary action is "Senden (N)" — clicks invoke the existing
onSubmit logic which POSTs to /api/team/broadcast and on success
shows the per-recipient report inline then closes via the
setTimeout(close, 2500) pattern.
The recipient-list toggle + template dropdown + markdown placeholder
hints are unchanged.
i18n + the .broadcast-recipient-* / .broadcast-recip-* / .broadcast-hint
/ .broadcast-error / .broadcast-success content classes are unchanged.
Rewrite atop the unified openModal() primitive (Slice A). Drops the
per-modal ESC + focus + backdrop + close-button handlers — the
primitive owns them.
New three-section body per design §2:
1. Editable fields. Every editable column on the entity, per m's Q1
Reading A lock-in:
deadline: title, due_date, original_due_date, warning_date,
rule_code, description, notes, event_type_ids
(attached via the existing event-types picker).
appointment: title, start_at, end_at, location, appointment_type,
description.
2. Read-only context. Project title, requester, requested_at, current
approval status. Renders as a definition-list with muted dt/dd
pairs so the eye lands on the editable section first.
3. Vorschlagskommentar (note). Always present, prominent.
Block labels matching /deadlines/new + views editor — reuses the
existing .form-field shapes for typography + spacing parity with the
rest of the app (m's Q6 lock-in).
inbox.ts gains projectTitle / requesterName / requestedAt hydration
from the per-row API response so the context section has data to
render. Falls back gracefully when missing.
Submit-button gate (in the openModal primary handler): refuses when no
field is dirty AND the note is empty. Mirrors the server's
ErrSuggestionRequiresChange.
CSS .approval-suggest-* classes added to global.css alongside the
modal primitive block (committed in Slice A).
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).
frontend/src/client/components/modal.ts — new openModal() primitive,
native <dialog>-backed. The browser handles top-layer stacking, ESC,
ARIA, and focus trap. We layer on top:
- browser back-button closes the modal (history.pushState on open +
popstate listener, matching m's Q5 lock-in)
- focus restoration to whatever was focused before open (the native
<dialog> doesn't do this)
- backdrop click closes
- close (×) button mandatory in the header, always rendered
CSS (global.css):
- dialog.modal + .modal__{header,title,close,body,footer} block. Sizes
sm/md/lg/full via data-size attr.
- Phone breakpoint (≤32rem): full-screen takeover sitting ABOVE the
PWA bottom-nav. max-height accounts for --bottom-nav-height (56px)
and margin-bottom keeps the nav visible.
- Legacy .modal-overlay / .modal-card / .modal-content / .modal stay
in place for the ~7 unmigrated modals — the new BEM-style .modal__*
avoids colliding with the legacy hierarchy. Cleanup is a follow-up
PR after the last legacy modal flips.
i18n keys + i18n-keys.ts regenerated:
- modal.close.label (DE/EN)
- approvals.suggest.section.editable / .context (DE/EN)
- approvals.suggest.context.{project,requester,requested_at,approval_status} (DE/EN)
- approvals.suggest.field.{original_due_date,warning_date,rule_code,description} (DE/EN)
- approvals.suggest.event_type_picker_unavailable (DE/EN)
(Slice C consumes the suggest.section/context/field keys; bundling them
here keeps the i18n.ts diff coherent.)
Sync engine pivots from scalar user_caldav_config.calendar_path to the
binding-driven loop over paliad.user_calendar_bindings. Invisible-but-shippable:
existing users keep working through the bootstrap binding row mig 101 created
for them; new bindings (Slice 2b UI) plug into the same loop.
- mig 107 — paliad.caldav_sync_log.binding_id (nullable FK ON DELETE SET NULL
so audit history survives binding deletes) + partial index. Idempotent.
- CalendarBindingService — full CRUD + ListEnabled/ListAllEnabled, scope
validation mirrors the CHECK constraints from mig 101.
- AppointmentTargetService — UpsertAfterPush, StaleForBinding,
FindByUIDAndBinding. Authoritative source of per-target state going forward.
- CalDAVService rewritten: per-binding inner loop, ForBinding() scope filter
(all_visible / personal_only / project — hierarchy scopes parked for Slice 3).
- REPORT calendar-multiget in caldav_client.go — collapses N GETs/min to one
multistatus REPORT (fits inside iCloud/Google rate windows).
- Read-only GET /api/caldav-bindings (write APIs come in Slice 2b).
- caldav_sync_log writes carry binding_id; pre-mig-107 rows stay NULL.
First migration to land via the new gap-tolerant runner (boltzmann c85c382).
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.
Adds a Datenexport action button at the end of the project-detail tabs
nav. Hidden by default; revealed when canExportProject() returns true
(global_admin OR direct team responsibility ∈ {lead, member}) — mirror
of the server-side §4 gate. Server re-enforces on the request.
Click handler swaps in a transient <a download> that hits
GET /api/projects/{id}/export — browser handles the download via
Content-Disposition. Same pattern as the personal export in
client/settings.ts.
4 new i18n keys (DE+EN):
- projects.detail.tab.export (n/a — uses .export.button on the action)
- projects.detail.export.button = "Daten exportieren" / "Export data"
- projects.detail.export.tooltip with hint about subtree inclusion
Total i18n keys now 2479.
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.
Pure helper + tests, no schema change. Reformats project.patent_number from
'EP 1 234 567 B1' to 'EP 1 234 567 (B1)' for UPC briefs (e.g. SoC / SoD
templates). Mirrors legalSourcePretty's shape: pure function, no schema,
register as {{project.patent_number_upc}} in the variable bag, 8 unit tests.
51 LoC helper + 35 LoC tests (vs the ~40+6 design estimate — small extras
for UPC documentation block and a couple more edge cases).
Slice 2 .docx templates (3 DE-LG + 2 UPC-CFI + 2 family skeletons) are
authored separately in HL/mWorkRepo via the python-docx flow; that side
of Slice 2 follows in a separate commit on the templates repo.
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.
Replace single-counter golang-migrate tracker with a hand-rolled runner
over embed.FS that tracks applied versions as a set in
paliad.applied_migrations. Fixes the 2026-05-20 production drift where
mig 103 was silently skipped (fermi's mig 104/105 deployed first → counter
jumped past 103 → Slice A schema never installed; recovered manually).
Now: any embedded migration not present in applied_migrations gets
applied on next deploy, regardless of authoring order. Race can't repeat.
- New hand-rolled ApplyMigrations over embed.FS in internal/db/migrate.go.
- Acquires pg_advisory_lock(hash('paliad.applied_migrations') → int64).
- Creates paliad.applied_migrations(version, name, applied_at, checksum) if missing.
- Bootstraps from paliad.paliad_schema_migrations.version=106 when
applied_migrations is empty (INSERT 1..106 with ON CONFLICT DO NOTHING,
checksum=NULL — verified by hand for mig 103 which was manually applied).
- Scans embed.FS for \d+_*.up.sql.
- Hard-fails on version collisions (≥2 files at same version) and on
rename mismatch (DB name vs disk name for already-applied version).
- Applies pending ASC, each in one tx with INSERT + sha256(file_bytes).
- Drops github.com/golang-migrate/migrate/v4 from go.mod.
- Test suite updated: internal/db/migrate_test.go and cmd/server/main_smoke_test.go
read paliad.applied_migrations. Dirty-flag check removed.
Drift-detection verify is deferred (checksums populated but not verified
in v1). Down-migrations remain on-disk as reference but not callable from
the runner (no v1 use case). Legacy tracker tables drop in a follow-up
mig 108 after burn-in.
Priority merge: unblocks parallel migration work by leibniz (mig 107/108
on t-paliad-212 CalDAV Slice 2) and newton (mig 107 on t-paliad-219
dashboard).
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.
Read-only audit of the t-paliad-207 surface per paliadin's 2026-05-20
re-engage instruction. Six commits shipped under this task are now
merged. Two larger follow-ups (m/paliad#39 youpc-laws ingest + #41 DE
combined timeline) are filed with concrete scope. Remaining tail is
optional polish, best handled as discrete issues rather than a parked
inventor.
Final slice on the suggest-changes feature. Pure i18n addition (4 keys,
DE + EN). The existing convention-based translateEvent() helper picks
up event.title.{slug} / event.description.{slug} keys by event_type, so
the new deadline_approval_changes_suggested + appointment_approval_
changes_suggested events light up the Verlauf timeline (projects-
detail.ts) and the admin audit log without any renderer changes.
t-paliad-216 complete across all 3 slices:
- Slice A (backend) → merged 6a20241
- Slice B (frontend) → merged 741cab4
- Slice C (Verlauf) → this merge
Feature is now live: approver opens an editable modal on a pending
update-request, edits the requester's proposed values into a counter-
proposal + writes a Vorschlagskommentar, submits → server in one tx
closes the old row as changes_requested, reverts the entity, spawns a
new pending approval_request authored by the suggesting approver with
counter_payload as its new payload. Original requester now sees the
counter in /inbox and can approve / reject / counter-suggest back.
Adds the DE + EN event title + description keys for the two new
*_approval_changes_suggested event_types emitted by SuggestChanges
(Slice A). The existing translateEvent() helper picks up
event.title.{event_type} and event.description.{event_type} keys by
convention — no renderer code changes are needed; this slice is pure i18n.
Surfaces covered:
- projects-detail.ts Verlauf tab (per-project timeline)
- admin-audit-log.ts admin audit table
Both call translateEvent() which now resolves the new keys.
No icon system is tied to event_type slugs; no server-side event_types
registry needs the new types pre-registered (the slugs are emitted into
project_events.event_type as free text by the service layer, then
localised at read time).
This is the final slice for t-paliad-216. Slice A (mig 103 + service +
handler + tests), Slice B (frontend modal + /inbox UI + back-link
hydration), Slice C (Verlauf i18n) all on main now.
Slice B per docs/design-approval-suggest-changes-2026-05-19.md §3.4-3.6.
Slice A backend (mig 103 + service + handler + tests) already merged at
6a20241; this is the user-facing layer.
- i18n — DE + EN keys for suggest-changes UI: button label
("Änderungen vorschlagen" / "Suggest changes"), changes_requested
status pill ("Abgelehnt mit Vorschlag" / "Declined with changes"),
modal title + buttons + validation errors, filter chip, back-link.
- shape-list + filter chip — 4th action button alongside
approve/reject/revoke, only rendered for lifecycle='update' rows
(the only entity-mutation flow worth counter-proposing on; create
and delete don't make sense). `changes_requested` filter chip added
to /inbox.
- approval-edit-modal component — new
frontend/src/client/components/approval-edit-modal.ts. Renders the
requester's payload as editable fields per entity_type (deadlines:
due_date / title / description; appointments: title / start_time /
end_time / location). Free-text Vorschlagskommentar textarea at the
bottom. Submit gated until form is dirty OR note has text — mirrors
the server's ErrSuggestionRequiresChange guard so the user can't
send a no-op.
- /inbox wiring — clicking the suggest-changes button opens the modal
(not window.prompt). POST to the new endpoint; on success, refresh
the bar: old row flips to changes_requested, new pending row
appears.
- Server-side back-link hydration — extended the ApprovalRequestView
with a computed next_request_id (reverse lookup on
previous_request_id) so the changes_requested row can render
"→ Neuer Vorschlag von {approver}" without an extra round trip.
Single LEFT JOIN added to the hydrator.
Bun build clean (2473 i18n keys). Go build + tests green.
Slice C (Verlauf integration — *_approval_changes_suggested event
rendering on the project / deadline / appointment timelines) remains.
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.
Suggest-changes branches off into handleSuggestChanges() instead of the
generic POST-with-prompt-note path. It:
1. Fetches the full request payload + pre_image via GET
/api/approval-requests/{id} — list payload may be stale.
2. Opens approval-edit-modal with the entity_type-appropriate fields
pre-populated.
3. On submit, POSTs to /api/approval-requests/{id}/suggest-changes
with {counter_payload, note}.
4. On success, refreshes the filter bar (the OLD row flips to
changes_requested, the NEW pending row appears) and the inbox badge.
5. Stamps data-spawned-request-id on the OLD row's <li> so downstream
code / tests can locate the counter without a re-query.
The error mapping (mapApprovalError) gains the two new codes
suggestion_requires_change + suggestion_lifecycle_invalid plus the
generic codes already handled. Body reads now go through `body.code`
preferentially with `body.error` fallback — the server uses {code,
message} envelopes.
New module: frontend/src/client/components/approval-edit-modal.ts.
The approver clicks "Änderungen vorschlagen" on a pending update-lifecycle
row; this modal opens with the requester's original payload pre-populated
in editable date inputs (per entity_type allowlist):
- deadline: due_date, original_due_date, warning_date
- appointment: start_at, end_at
The pre_image value for each field renders as a "Vorher" hint so the
approver sees what's being changed before they commit a counter.
A free-text "Vorschlagskommentar" textarea sits below the inputs. The
submit button stays disabled until the form is dirty OR the note has
non-whitespace content — mirrors the server's ErrSuggestionRequiresChange
no-op guard so the user doesn't bounce off a server-side 400.
API: openApprovalEditModal({entityType, lifecycleEvent, payload,
preImage}) returns Promise<{counterPayload, note} | null>. null = user
cancelled (ESC, overlay click, Cancel button). counterPayload contains
only fields that the user changed; unchanged keys are omitted (the
server's buildRevertSetClauses ignores absent keys cleanly).
Lifecycles other than "update" are guarded with an alert + resolve null —
shape-list.ts hides the button for them, but the modal is defence-in-
depth.
shape-list.ts:
- Pending-row action group extends to four buttons. suggest_changes is
only rendered for lifecycle='update' rows (the backend rejects other
lifecycles with ErrSuggestionLifecycleInvalid).
- ApprovalAction union widened to "approve" | "reject" | "revoke" |
"suggest_changes". Disabled-reason logic shared with approve/reject
(viewer_can_approve gate).
- Status pill renders "Abgelehnt mit Vorschlag" for changes_requested
via the existing approval-pill--historic style — no new colour token.
- ApprovalDetail picks up counter_payload + next_request_id. When a
row is changes_requested AND a next_request_id is present, render a
back-link "→ Neuer Vorschlag von {name}" pointing at the new pending
row (server-side hydrated via correlated subquery on
previous_request_id, indexed by mig 103's partial index).
filter-bar/axes.ts:
- APPROVAL_STATUSES gains "changes_requested" — the chip shows up in
the /inbox filter cluster alongside pending/approved/rejected/revoked.
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.
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.
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.
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).
§0a captures m's locked picks across all 8 questions. Two divergences from
inventor recommendations reshape the model:
- Q4: hybrid — approver edits the proposed values (counter-payload) AND/OR
leaves free-text in decision_note. Adds counter_payload jsonb column.
- Q6/Q7: the counter is treated as a NEW pending approval_request authored
by the approver, not an "edit and resubmit" CTA on the requester side.
Original requester sees the old row as changes_requested ("Abgelehnt mit
Vorschlag") and the new row as pending — they can approve it themselves
if eligible (they're no longer the requested_by). 4-Augen still holds.
§3 implementation sketch rewritten: SuggestChanges atomically closes the
old row, applyRevert's the entity, spawns a new pending row with
counter_payload as payload + previous_request_id linking back, re-applies
the counter via write-then-approve, emits both *_approval_changes_suggested
and *_approval_requested events. Migration 103 adds the CHECK value plus
counter_payload jsonb + previous_request_id FK + index. Slice plan trimmed
to backend / frontend-modal / Verlauf-integration.
Inventor draft of the fourth approval action alongside approve / reject /
revoke. Open questions in §2 will be resolved via AskUserQuestion before
any coder work. Recommendations folded in inline.
Verified live state before designing: status enum already carries an
unused 'superseded' value; entity approval_status is approved/pending/legacy
only; decision_note exists as free text; the existing decide() kernel
handles approve / reject / revoke with a single switch.
Three parked commits from fermi's 2026-05-18 interactive session, never
engaged at the time; m greenlit 2026-05-20 09:43:
- mig 104 (was 101): strip rule-cite brackets from Einspruch names + flip
CCR priority informational → optional. m's 18:01 + 18:08 corrections.
- mig 105 (was 102): track-aware sequence reshuffle for upc.inf.cfi
(infringement → revocation → amendment within tied-date groups). m's
18:08 ask about Replik-before-Erwiderung-NichtigkeitsWK ordering.
- Notes toggle UI: per-rule notes default to compact ⓘ hover hint;
"Hinweise anzeigen" switch in the toggle bar expands them inline.
Shared localStorage between verfahrensablauf and fristenrechner.
m's 18:21 ask.
Renumbered from 101/102 because leibniz CalDAV claimed mig 101 and
archimedes system_audit_log claimed mig 102 between fermi's parked
session and now. Mig 103 reserved for hertz suggest-changes Slice A
(in flight). Both go and frontend builds green.
m's ask 2026-05-18 18:21: per-rule descriptive notes ("Innerhalb von 1
Monat ab Zustellung der Klage. Drei mögliche Gründe…") are noisy in the
default timeline view. Make them optional — small ⓘ icon next to the
meta line by default with full text on hover; switch in the toggle bar
expands them inline when the user wants the wall of text.
**Renderer (verfahrensablauf-core.ts)** — `CardOpts.showNotes?: boolean`
gates two render paths:
- on → `<div class="timeline-notes">…</div>` (today's behaviour)
- off → `<span class="timeline-note-hint" tabindex=0 role=note
aria-label=… title=…>ⓘ</span>` inside the meta line (browser
title for hover, aria-label for screen readers, tabindex for
keyboard accessibility)
Pass-through wired in renderColumnsBody too so the columns view picks
up the toggle equally.
**Toggle UI** — added a checkbox row to the existing `fristen-view-toggle`
bar on both /tools/verfahrensablauf and /tools/fristenrechner:
"Hinweise anzeigen" / "Show details". CSS modifier
`.fristen-notes-option` separates it from the radio view-picker with
a leading border-left.
**State** — `paliad.fristen.notes-show` localStorage key (shared
between both pages so the preference carries across), default off,
re-render on flip.
i18n: 1 new key DE + EN (deadlines.notes.show). Build clean.
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).
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.