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.
New "Schriftsätze" tab on /projects/{id}, lazy-loaded by the
existing tab switcher (same pattern as the Checklisten tab — only
hits the API when the user actually opens it). Lists the project's
filing rules in a 4-column table: name (with submission_code under
it), party, legal basis, action button.
Action column shows [Generieren] for rules with a resolvable
template and "Keine Vorlage" / "No template" for rules without one.
The generate button fetches the .docx via XHR, parses the
Content-Disposition filename, creates an object URL, and triggers
the browser download via a hidden <a download>. Disabled
mid-flight to prevent double-submits.
The table opts into the `.entity-table--readonly` modifier — rows
themselves don't navigate; only the inline button does (avoids the
"clickable row that isn't" UX lie called out in the project
CLAUDE.md frontend conventions).
11 new i18n keys per language. New CSS block for the submission-row
typography (name + dim-grey code stacked vertically, right-aligned
action cell, italic no-template hint).
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.