m/paliad#96 — per-event-card optional choices on the Verfahrensablauf
timeline. This commit lands the schema + service layer.
Migration 129:
- paliad.project_event_choices table (project_id, submission_code,
choice_kind ∈ {appellant, include_ccr, skip}, choice_value) with
UNIQUE(project_id, submission_code, choice_kind) for idempotent
re-pick, RLS via paliad.can_see_project.
- paliad.deadline_rules.choices_offered jsonb — opt-in declaration of
which choice-kinds each rule offers. Seeded for every decision rule
(appellant), every priority='optional' rule (skip), and the two
Klageerwiderung rules (upc.inf.cfi.sod + de.inf.lg.erwidg) with
include_ccr.
Live verification before authoring:
- rule_code is NULL on every decision row → submission_code is the
join key (matches AnchorOverrides plumbing in fristenrechner.go).
- upc.inf.cfi.sod is the UPC Klageerwiderung, not upc.inf.cfi.def
(rejected the design doc's first guess; SELECT name ILIKE
'Klageerwiderung' confirmed).
Go service:
- models.ProjectEventChoice + DeadlineRule.ChoicesOffered.
- EventChoiceService: ListForProject / Upsert (with audit-log row to
paliad.system_audit_log) / Delete. Pure-helper ToCalcOptionsAddendum
+ per-kind value validation + unit tests.
Design: docs/design-event-card-choices-2026-05-25.md §3 + §6.
Implements three Tier 3 primitives from curie's bulletproof completeness
audit (docs/research-deadlines-completeness-2026-05-25.md §10 T3.1, T3.2,
T3.5), per m's 2026-05-25 15:29 steer to build the full primitives
instead of documenting workarounds.
Primitive 1 — duration_unit='working_days':
Calculator walks day-by-day skipping weekends + court holidays via
HolidayService.IsNonWorkingDay. Event day is not counted; result is
always a working day for the (country, regime). Unlocks T1.8/T1.9
modeling and the R.198 / R.213 alt leg.
Primitive 2 — combine_op='max' (and 'min'):
When alt_duration_value + alt_duration_unit + combine_op are set, the
calculator evaluates both legs and picks the later (max) or earlier
(min) of the two adjusted end dates. The DB already had two rules
shaped this way ('31d OR 20wd, whichever is longer' — R.198 / R.213);
the calculator was silently dropping the alt leg.
Primitive 5 — timing='before' backward snap-to-working-day:
For backward rules (R.109.1: 1 month before oral hearing; R.109.4:
2 weeks before) the calculator now snaps to the PRECEDING working day
when the computed cut-off lands on a weekend/holiday. Forward snap
(the prior behavior) would push the cut-off past the statutory limit
and miss the deadline. Adds HolidayService.AdjustForNonWorkingDays-
Backward as the symmetric counterpart of AdjustForNonWorkingDays.
Migration 128 — DB schema:
Adds CHECK constraints on deadline_rules.duration_unit and
alt_duration_unit pinning the allowed set to days/weeks/months/
working_days. Live data audited and passes (no rows excluded).
Tests (12 new + 1 flipped):
- 5 working_days cases: forward over weekend, 20wd anchored on Fri,
across Karfreitag/Ostermontag, across year boundary, backward
from Friday, anchored on Saturday.
- 2 backward snap cases: Sun → preceding Fri; cluster Sun → Sat →
Karfreitag → Thu.
- 4 combine_op cases: max with primary winning, max with alt winning
over Christmas+Neujahr cluster, min with primary winning, NULL-alt
short-circuit.
- TestCalculateEndDate_BeforeTiming renamed and flipped from forward
(Sun → Mon, the prior wrong behavior) to backward (Sun → Fri).
No regression on existing rules: every pre-existing days/weeks/months
'after' rule still computes the same date. Frontend build + full
go test ./internal/... clean.
Slot 128 assigned per next-available convention (mig 127 = Wave 0
Tier-0 fixes, mig 128 = Wave 2 Tier-3 Slice A primitives).
Substrate changes that turn /inbox from approvals-only into the
unified notification surface m asked for.
- Migration 126: paliad.users.inbox_seen_at (high-watermark read cursor;
pending approval_requests bypass it per design §3).
- KnownProjectEventKinds gains note_created, our_side_changed,
deadline_updated/deleted, deadlines_imported. New
InboxProjectEventKinds curated subset (head's Q1=A lock).
- InboxSystemView spans [approval_request, project_event]; defaults to
past 30 days, newest first, row_action="inbox".
- view_service.allowedProjectEventKinds drops *_approval_* audits when
ApprovalRequest is also in spec.Sources (no double-count).
- RunSpec resolves the caller's inbox_seen_at once and threads it
through viewSpecBounds; runProjectEvents excludes self-authored
events and rows older than the cursor when unread_only is set.
Decided approval_requests follow the cursor; pending always survives.
- ApprovalService.UnseenInboxCountForUser (unified badge count) +
MarkInboxSeen + InboxSeenAt service methods.
- GET /api/inbox/count returns the unified count; new
POST /api/inbox/mark-all-seen advances the cursor (optional up_to=).
Tests cover the InboxSystemView shape, the audit-dedup helper, the
isApprovalAuditKind matcher, and the no-narrow-no-approvals nil path.
Replik and Duplik had parent_id = NULL with a 4-week placeholder
duration, so the projection anchored both off the proceeding's
trigger date (Klageerhebung) - both rows rendered at the same
calendar date AND before Klageerwiderung.
Migration 124 anchors Replik on Klageerwiderung
(de.inf.lg.erwidg) and Duplik on Replik, and marks both
is_court_set = true with legal_source DE.ZPO.273. The 4-week
placeholder duration is retained so the timeline gives a sane
notional date for each row; the lawyer overrides it with "Datum
setzen" once the court issues the actual period.
Each UPDATE is guarded by parent_id IS NULL so a re-apply against
a DB that already carries the fix no-ops cleanly (mig 095
convention). No new audit-log rows on idempotent re-runs.
Slot note: originally landed as 123 in an earlier iteration;
cronus's t-paliad-246 Backup-Mode migration won slot 123 in the
parallel merge race, so this migration shifted to slot 124.
ZPO citations in the migration comment per the t-paliad-264 brief:
- Klageerhebung - section 253 ZPO
- Anzeige Verteidigungsbereitschaft - section 276 Abs. 1 S. 1 ZPO
- Klageerwiderung - section 276 Abs. 1 S. 2 + section 277 ZPO
- Replik / Duplik - vom Gericht bestimmte Frist
(section 273 ZPO Anordnungskompetenz; section 282 ZPO
prozessuale Foerderungspflicht)
Verified ordering for trigger 2026-05-25:
Klage 2026-05-25 Mon
Anzeige 2026-06-08 Mon
Klageerwidg 2026-07-06 Mon
Replik 2026-08-03 Mon
Duplik 2026-08-31 Mon
Each row strictly later than the previous; Replik and Duplik no
longer collide on the same date and no longer precede the
Klageerwiderung.
Cross-cutting Wiedereinsetzung sub-rows (PatG §123 / ZPO §233 /
EPC Art.122 / DPMA PatG §123 / UPC R.320) used to bypass the
forum-bucket chip selection by design — every chip combination
returned all five rows. m/paliad#97: chip the chips through
to triggers via legal_source inference.
- mig 123 backfills the missing deadline_rules row for trigger
207 (UPC R.320 Wiedereinsetzung, orphaned by mig 063 because
mig 092 dropped event_deadlines before that path was seeded)
and rebuilds paliad.deadline_search with a LEFT JOIN on
deadline_rules so cross-cutting trigger pills carry their
structured legal_source.
- DeadlineSearchService gains ForumToLegalSourcePrefixes (10
buckets → UPC. / DE.ZPO. / DE.PatG. / EU.EPC + EU.EPÜ)
paralleling ForumToProceedingCodes. Rule pills still narrow
by proceeding_code; trigger pills now narrow by legal_source
LIKE prefix. Multiple chips union the prefix allow-list as
expected.
- Live golden-table test gains a Wiedereinsetzung×forum matrix
plus a multi-chip union case, and the existing 4-pill assertion
is updated to the now-5-pill state (mig 063 added trigger 207).
Branch: mai/hermes/gitster-event-type-modal.
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.
Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
rule renders read-only as 'Auto | <Name · Citation>' next to the
field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
nullable column, migration 122). Mutually exclusive with rule_id at
the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
the current Type — no stale state.
Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
Existing rows: empty custom_rule_text + non-null rule_id = Auto-
equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
RuleSet=true is the discriminator so absent fields don't overwrite
the row (PATCH semantics). RuleID and CustomRuleText are mutually
exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
surfaces can render it.
Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
dropdown, the override-warning slot, and the collapsed-by-Regel Typ
view. Strip the (Rule→Type) auto-fill machinery — direction is now
one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
rule by project's proceeding, then jurisdiction match, then first
candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
Custom text → proceeding → fallback) so the recipe still produces a
sensible title even when Custom is used.
Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
enterEdit initialises the mode from the persisted deadline; Save
PATCHes with rule_set:true + the chosen rule pointer.
Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
Standardtitel, events.ts ruleDisplay (REGEL column on /events),
projects-detail.ts Fristen table, views/shape-list.ts generic
rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
name + citation chip separately and matches the canonical pattern;
no change needed. Schriftsätze table is column-shaped (name + code
in distinct columns) and out of scope per the addendum.
CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
family (retired with the catalog dropdown).
i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
auto_pick_type, custom_badge, custom_placeholder,
mode.toggle_to_auto, mode.toggle_to_custom).
Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).
Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).
Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
Concerns A + B + C from m/paliad#81:
A. Browse-a-proceeding (/tools/verfahrensablauf) gains a side selector
(Kläger/Beklagter/Beide) and an appellant selector. The side selector
swaps which column labels which user-side; the appellant selector
collapses party='both' rules into the appellant's column (no mirror)
so role-swap proceedings (Appeal, etc.) stop showing every row
twice in the timeline. Both selectors are URL-driven (?side= +
?appellant=) and re-render without a backend round-trip.
The appellant row hides itself for proceedings without an appellant
axis (first-instance Inf/Rev/Opp) via a small allowlist.
B. UPC Appeal trigger-event caption now reads "Anfechtbare Entscheidung"
/ "Appealable Decision" instead of falling back to the proceeding
name ("Berufungsverfahren" / "Appeal"). Implemented as an optional
trigger_event_label_{de,en} column on paliad.proceeding_types (mig
121); the frontend prefers it over the proceedingName fallback that
fires when no rule has IsRootEvent=true. No new deadline rules, no
slug changes (hard rule from the issue).
C. Parameter contract for the column projection is unified in
bucketDeadlinesIntoColumns(deadlines, {side, appellant}) — a pure
helper extracted from renderColumnsBody so the routing behaviour
stays unit-testable without a DOM. Tests cover the default mirror,
appellant-collapse for both sides, side-swap of column ownership,
the combined case, and row alignment by dueDate.
Verification
- go build ./... clean
- go test ./... all green
- bun run build (frontend) clean
- bun test (frontend/src) 110/110 pass (12 new + 98 prior)
- Migration 121 applied to paliad schema; UPC Appeal proceeding now
carries the curated trigger label pair.
Out of scope (filed for follow-up): per-rule role tagging so
respondent-side filings (Response to Appeal, Cross-Appeal) land in
the respondent's column when an appellant is selected. The current
issue scope (one-row-per-deadline collapse) is delivered; the
realistic-per-row routing needs a deadline_rules schema bump that
the hard rules of #81 excluded.
Adds an end-to-end project-optional path for Schriftsatz drafts:
- Migration 120 drops NOT NULL on paliad.submission_drafts.project_id
and rewrites the four RLS policies to gate purely on user_id when
project_id IS NULL, otherwise on paliad.can_see_project. Down
refuses to run if project-less rows exist (safer than silent
data corruption).
- SubmissionDraft.ProjectID becomes *uuid.UUID end-to-end. Service
layer skips project/parties/deadline lookups when nil and exposes
DraftPatch.ProjectID for the "Projekt zuweisen" affordance.
ListAllForUser LEFT JOINs paliad.projects so project-less drafts
surface in the global index next to project-scoped ones.
- New HTTP surface:
GET /submissions/new (picker page)
GET /submissions/draft/{draft_id} (editor for any draft)
GET /api/submissions/catalog (catalog without project)
POST /api/submission-drafts (project-less or attached)
GET/PATCH/DELETE /api/submission-drafts/{draft_id}
POST /api/submission-drafts/{draft_id}/export
Existing /api/projects/{id}/submissions/... routes remain bit-
identical so the project-scoped flow keeps working unchanged.
- Frontend: /submissions/new lists the full cross-proceeding catalog
grouped by proceeding, filterable by text + chip. Each row offers
"Ohne Projekt" (instant draft) or "Mit Projekt…" (modal picker
with autocomplete over visible projects). /submissions index gains
a prominent "Neuer Entwurf" CTA and an empty-state CTA pointing at
the picker. The editor renders a banner + "Projekt zuweisen"
action when project_id is null; assigning persists project_id and
redirects to the project-scoped URL.
Audit + project-event writes detect d.ProjectID == nil; the audit
row's scope flips to 'user' (scope_root = user_id) and the
project_events row is skipped entirely.
Adds the dedicated Submissions/Schriftsätze editor at
/projects/{id}/submissions/{code}/draft (and …/draft/{draft_id}) per
docs/design-submission-page-2026-05-22.md.
Lawyer picks (or creates) a named draft, edits placeholder variables
in a sticky sidebar, sees a read-only HTML preview of the merged
document body, and exports a .docx with project state + lawyer
overrides resolved. Drafts persist in paliad.submission_drafts
keyed on (project_id, submission_code, user_id, name) with RLS via
can_see_project; updates and deletes additionally gated on owner-only
(Q-E4 owner-scoped pick, m-confirmed).
Resurrected from git history per the design's "no rewrite" plan:
SubmissionVarsService ← commit 1765d5e (Slice 2 with patent_number_upc)
SubmissionRenderer ← commit 8ea3509 (in-house merge engine — the
lukasjarosch/go-docx library refuses sibling
placeholders in one run, which patent submissions
use routinely)
ConvertDotmToDocx ← existing format-only convert (kept; reused as
pre-pass so .dotm inputs strip macros before
merge)
New code:
paliad.submission_drafts migration 119 (idempotent — DROP POLICY IF EXISTS
+ CREATE; CREATE OR REPLACE for the shared trigger
function). Applied to live DB.
SubmissionDraftService CRUD + autosave-friendly Update + Export/RenderPreview
entry points
RenderHTML method new on the renderer; walks the same merged
document.xml as Render but emits HTML for the
preview pane (Q-E3 server-side pick)
7 API handlers list / create / get / patch / delete / preview / export
2 page routes /draft and /draft/{draft_id}
submission-draft.tsx stand-alone editor page (header / sidebar /
preview / export button); served via
dist/submission-draft.html
submission-draft.ts client bundle — autosave (500ms debounce),
draft switcher, rename, delete, export with
blob download
Tab integration: existing /projects/{id}/#tab-submissions rows get
[Bearbeiten] alongside the existing [Generieren] one-click format-only
path — additive, no removal.
Slice A template: universal HL Patents Style .dotm (same path
t-paliad-230 uses). resolveSubmissionTemplate carries the
submission_code parameter so Slice B's TemplateRegistry wiring (per-
code .docx fallback chain) is a one-function swap.
Audit trail: paliad.system_audit_log row per export
(event_type='submission.exported') + paliad.project_events row
(event_type='submission_exported', timeline_kind='custom_milestone')
so the export surfaces on the project's Verlauf / SmartTimeline. No
paliad.documents write (Q-E2 inventor pick, head-ratified).
Tests: TestRender_* / TestPlaceholderRegex_* / TestRenderHTML_* +
TestLegalSourcePretty / TestOurSide* / TestPatentNumberUPC — all
green. go build / go vet / go test ./internal/... / bun run build all
clean.
Migration slot taken: 119.
m's 14:56 observation: long Paliadin turns showed "Verbindung verloren —
Antwort wird nachgereicht …" but never delivered. The aichat backend
finished the turn upstream; paliad's HTTP client had given up at 130 s
and the legacy filesystem janitor never ran for the aichat path.
Three intertwined fixes, all shipped together because they share the
same wire shape and the same UI states:
1. Switch the aichat backend to /chat/turn/stream
- new AichatPaliadinService.RunTurnStream relays incremental chunks
- SSE parser handles default `data:` frames (chunk/meta/done/error)
and named `event: heartbeat` frames per the upstream contract
- no more 130 s hard ceiling — stream stays open as long as data or
heartbeats flow; silenceTimeout (90 s) catches a true upstream
stall instead
2. Proof-of-life thinking events
- handler emits `event: thinking` every 5 s while the upstream is
silent (synthesised locally) AND relays aichat's `heartbeat`
events as thinking pings
- frontend renders a lime-dot pulse + monospace counter inside the
assistant bubble — the user can SEE the chat is still working
3. Honest disconnect copy + real late-recovery
- new dispatching endpoint GET /api/paliadin/turns/{id}/recover
- aichat backend: asks aichat via GET /chat/conversations and
/chat/conversations/{id}/turns whether the turn actually finished
- legacy backend: falls through to the local row read (janitor)
- frontend swaps "wird nachgereicht" → "Lade frische Antwort …"
while the recovery polls; on confirmed "lost" swaps to
"Antwort konnte nicht zugestellt werden — bitte erneut stellen"
- migration 118 adds aichat_conversation_id to paliadin_turns so
the recovery has a fast path when the done frame arrived before
the drop
Streaming + recovery are a no-op for PALIADIN_BACKEND=legacy: the
StreamingPaliadin interface is detected via type assertion, the
LocalPaliadinService stays on the one-shot RunTurn + filesystem
janitor path.
13 new unit tests cover the SSE parser, the conversation-API client,
and the match-assistant-response helper.
go build ./... + go test ./internal/... + go test ./cmd/server/...
+ bun run build all clean.
Three additions on top of Slice B's edit-mode chrome.
**Catalog expansion (2 new widgets, default-hidden — opt-in via picker):**
- pinned-projects: surfaces a list of the user's pinned matters via the
pre-existing PinService (mig 062/063, pre-dates t-paliad-219). New
DashboardService.loadPinnedProjects joins paliad.user_pinned_projects
to paliad.projects under the standard visibility predicate, preserves
pinned-at-DESC order, capped at PinnedProjectsCap=20. PinnedProjects
[]PinnedProjectRef grows DashboardData; SetPinService wired
post-construction to mirror the SetApprovalService pattern.
- quick-actions: pure UI affordance with three buttons linking to the
existing /projects/new, /deadlines/new, /appointments/new routes. No
backend payload, no settings schema.
Both default-hidden — m's brief asked for "high-value adds"; injecting
new widgets into every user's dashboard unannounced would be loud.
Factory test relaxed: visibility now matches catalog.DefaultVisible
instead of the previous "all-visible" invariant.
**Firm-wide admin default (mig 117 + new service + 4 endpoints):**
- paliad.firm_dashboard_default: single-row table (id smallint PK CHECK
id=1) with layout_json + updated_by + updated_at. RLS: SELECT
authenticated, no INSERT/UPDATE policy (writes go through the
service-role connection behind the adminGate).
- FirmDashboardDefaultService Get/Set/Clear. Validates against the
catalog on Set so an admin can't seed an invalid layout.
- DashboardLayoutService.SetFirmDefaultService wires in the firm
source. Both GetOrSeed and ResetToDefault now prefer the firm
default over the code-resident FactoryDefaultLayout when one is set.
Nil-safe — empty firm row falls back to the factory layout, transient
DB errors fall back too (a blip can't strand a user without a
dashboard).
- HTTP: GET / PUT / DELETE /api/admin/firm-dashboard-default (admin-
gated). POST /api/me/dashboard-layout/promote: admin convenience —
reads the admin's own current layout and stashes it as the firm
default (saves the JSON-editor step; admins edit via /dashboard's
normal editor, then click Promote).
**Frontend (Slice B's edit-mode footer grew an admin button):**
- "Als Firmen-Standard speichern" button in the edit footer; hidden via
CSS-inline until syncPromoteButtonVisibility unhides for
global_admin. Confirm() → POST /promote → toast.
- The existing "Auf Standard zurücksetzen" copy stays the same — the
semantics now "firm default if set, else factory", which is the
desired surface: users see one canonical "Standard" link.
i18n: 13 new keys × DE+EN (dashboard.pinned.*, dashboard.quick.*,
dashboard.edit.promote*). i18n-keys.ts regenerated by build.
m/paliad#46.
go build ./... clean; go vet ./... clean
go test ./internal/... clean (Slice C catalog test + factory-default
test relaxation; FirmDashboardDefault round-trip tests gated on
TEST_DATABASE_URL)
Migration 117 dry-run: PASS (other dry-run failures are pre-existing
local-DB collisions on origin/main; mig 117 itself clean)
bun run build clean: dashboard.html carries new section markup + admin
button; dashboard.js bundles renderPinnedProjects + promote handler
+ all new i18n keys
m/paliad#61 Slice C backend.
Schema (mig 116, idempotent):
- ALTER paliad.checklists ADD COLUMN version int NOT NULL DEFAULT 1.
Pre-Slice-C rows default to 1 (the column was added with DEFAULT
so the UPDATE clause is a no-op safety net).
- ALTER paliad.checklist_instances ADD COLUMN template_version int.
NULL on existing rows — instance detail page leaves the "outdated"
badge off when the snapshot version is unknown.
Services:
- ChecklistTemplateService.Update — version bumps on title/body
changes (the meaningful edits that warrant notifying instance
owners). Pure metadata tweaks (description/court/reference/deadline)
update updated_at without bumping. Emits the new 'checklist.versioned'
audit event with prior_version + new_version metadata.
- ChecklistInstanceService.Create — captures snapshot_version
alongside the body snapshot.
- ChecklistCatalogService — CatalogEntry grew a Version field
(1 for static; live column for authored). ListVisible / Find
populate it.
- Models — Checklist.Version int; ChecklistInstance.TemplateVersion *int.
- /api/checklists/{slug} response now includes version so the
instance detail page can compare against the snapshot.
Migration verified live via BEGIN..ROLLBACK against paliad.checklists
and paliad.checklist_instances.
Build hygiene: go build/vet/test ./internal/... + TestBootSmoke
./cmd/server/ all green.
m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.
Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
recipient via xor-check: recipient_kind in {user, office,
partner_unit, project} with exactly one matching recipient_* column
populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
share branch uses inline ltree walk over projects.path because
can_see_project reads auth.uid() (NULL on service-role connection,
same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
set; accepts the matching kind/recipient pair
Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
required FK target, friendly 409 on partial-unique-index conflict),
Revoke (owner or global_admin), ListGrants (owner or global_admin;
enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
+ promoted_at/by + audit), Demote (global_admin → target visibility,
default 'firm', clears promoted_at/by; rejects demote of non-global
rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
'shared' as an author-set value; 'global' stays admin-only
Endpoints:
- GET /api/checklists/templates/{slug}/shares — list grants (owner/admin)
- POST /api/checklists/templates/{slug}/shares — grant
- DELETE /api/checklists/shares/{id} — revoke
- POST /api/admin/checklists/{slug}/promote — promote to global
- POST /api/admin/checklists/{slug}/demote — demote (body.target default 'firm')
Audit (paliad.system_audit_log):
- checklist.shared — recipient_kind + recipient_id in metadata
- checklist.unshared — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted — target_visibility
Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.
Hotfix-merge note: head shipped 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived
project codes from the ancestor tree) in one shift.
Migrations:
- mig 112_client_role_rework: widen paliad.projects.our_side CHECK to
seven sub-roles (claimant / defendant / applicant / appellant /
respondent / third_party / other); drop legacy 'court' / 'both'
and backfill rows to NULL (no-op on prod, defensive on staging).
- mig 113_projects_opponent_code: add paliad.projects.opponent_code
text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as
the middle segment when assembling auto-derived project codes.
Backend:
- internal/services/project_code.go — new package-level helpers
BuildProjectCode (single row) + PopulateProjectCodes (bulk, one
CTE-based round-trip). Walks the existing paliad.projects.path
ltree; custom paliad.projects.reference on the target wins.
- Wired into ProjectService.List, GetByID, ListAncestors, GetTree,
LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every
service entry-point that returns []models.Project / *models.Project
populates .Code before returning.
- Models: Project.OurSide doc widened; new Project.OpponentCode
(db:"opponent_code") and Project.Code (db:"-", projection-only).
- CreateProjectInput / UpdateProjectInput accept OpponentCode;
validateOpponentCode + nullableOpponentCode mirror our_side helpers.
- validateOurSide widens to the seven sub-roles; legacy 'court' /
'both' rejected at the service layer with a clear error before
the DB CHECK fires.
- derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent,
appellant → respondent; third_party / other / NULL pass through.
- submission_vars: project.code added to the placeholder bag.
ourSideDE / ourSideEN now use the gender-neutral "-Seite" /
"-Partei" suffix shape (Klägerseite / Antragstellerseite / ...);
better legal-prose default for a B2B patent practice, matches the
form labels which already used this shape (cf. head's soft-note on
Q4).
Frontend:
- ProjectFormFields: opponent_code on a new projekt-fields-litigation
block (hidden by default, shown when type=litigation); our_side
moved into projekt-fields-case and re-labelled "Client Role" /
"Mandantenrolle" with three <optgroup>s + seven options.
- project-form.ts: showFieldsForType toggles the new litigation
block; readPayload / prefillForm wire opponent_code; our_side
is now only emitted for type=case.
- fristenrechner: ourSideToPerspective widened to the seven sub-roles
(Active→claimant, Reactive→defendant, Other→null). ProjectOption
type literal updated.
- i18n.ts: new projects.field.client_role.* and
projects.field.opponent_code.* keys (DE+EN). Legacy
projects.field.our_side.* keys stay one release for cached
bundles + Verlauf event-history rendering of the new sub-roles.
Tests:
- TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3,
TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode,
TestValidateOurSideSubRoles pin the new pure helpers.
- TestOurSideTranslations widened to the seven sub-roles + new
prose shape; 'court'/'both' arms now return "" (legacy rejected).
- TestDerivedCounterclaimOurSide widened to the new flip map.
Migration slot history (this branch was rebumped twice on 2026-05-20):
mig 110 was claimed by m/paliad#51 (project_type_other, euler);
mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss).
Final slots 112 / 113.
go build && go test ./internal/... && cd frontend && bun run build
all clean.
Backend: mig 110/111 (will be renumbered after merging main),
validators + helpers widened, BuildProjectCode helper + projection
populator wired into List/GetByID/ListAncestors/GetTree/CCR. All
internal Go tests pass.
Frontend: ProjectFormFields conditional render — opponent_code on
litigation, our_side renamed to Client Role on case with grouped
optgroups. i18n keys for both DE and EN. fristenrechner perspective
mapping widened. project-form.ts payload reader/writer + showFieldsForType
toggle for new litigation block.
Migration slots about to be bumped (mig 110 was claimed by euler's
project_type_other on main).
#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an
inheritable role-edit gate via the materialised ltree path.
- migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase.
- services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate.
- services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column).
- services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError.
- handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage.
- handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip.
- frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg.
- i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs).
- tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table.
go build && go test -short ./internal/... && bun run build all clean.
m/paliad#51 (t-paliad-221) — the type chip filter on /projects used to
treat unclassified projects as a synthetic "Empty" bucket. Make 'other'
a first-class projects.type value so every row carries a meaningful
label and the filter UI stops needing a NULL/Empty shim.
- mig 110: extend projects.type CHECK to include 'other'; backfill any
NULL rows defensively (production query confirmed zero, but the
NOT NULL constraint isn't load-bearing once the IN-list changes).
- Go: add ProjectTypeOther constant; isValidProjectType + humanProjectType
recognise it; handler doc lists 'other' in the ?type whitelist.
- Frontend: new chip in the projects.tsx type filter, new option in the
Create-Project form, DE "Sonstiges" / EN "Other" labels for the
projects.type and projects.chip.type i18n families.
Also drops a stray data-i18n-text attribute on the existing 'project'
chip checkbox (it had no consumer in i18n.ts and the surrounding markup
was nesting a <span> inside an <input>).
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.
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.
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.
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.
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.
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).
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.
Schema-only landing for Slice 1 of the CalDAV multi-calendar design
(docs/design-caldav-multi-calendar-2026-05-19.md). Sync engine NOT
touched — Slice 2 wires the per-binding fan-out. After this migration:
- paliad.user_calendar_bindings — N bindings per user with scope_kind
∈ {all_visible, personal_only, project, client, litigation, patent,
case}. Hierarchy scopes anchor scope_id at paliad.projects(id).
Partial unique indexes enforce one binding per (user, scope_kind,
scope_id) for hierarchical scopes and one per (user, scope_kind)
for the scope-less roots. RLS mirrors user_caldav_config.
- paliad.appointment_caldav_targets — per-(appointment, binding) join
carrying caldav_uid + caldav_etag. UID stays canonical per
appointment so the same event in N cals shares one UID.
- Backfill — one all_visible binding per existing user_caldav_config
row, one target row per appointment already pushed. Maps target to
the creator's binding, matching today's Phase F semantics where the
creator's goroutine owns the etag.
Legacy paliad.appointments.caldav_uid / caldav_etag columns are
untouched (kept as denormalised pointers through Slice 1+2; dropped
in Slice 4 after telemetry).
Dry-run verified against live Supabase (PG 15.8): synthetic config +
appointment backfill creates exactly 1 binding + 1 target; re-run is a
no-op; all CHECK + unique-index constraints enforce as designed; final
assertions pass with 0 missing rows.
Prod impact at landing: 0 rows in user_caldav_config and 0 appointments
with caldav_uid — backfill is a true no-op. Slice 1 ships invisible.
Slice 1 of docs/design-paliad-test-strategy-2026-05-19.md — the test
infrastructure that would have caught mig 098 (digit-regex) and mig 099
(missing audit_reason) before the deploy hit prod.
Three new files + one route addition:
- Makefile: `make verify-migrations` (alias `verify-mig`) runs the
per-migration dry-run + boot smoke against TEST_DATABASE_URL. Fails
fast with a clear error if TEST_DATABASE_URL is unset so CI can't
silently pass a missing env var. `make test` and `make test-go`
cover the rest of the short / full Go suites.
- internal/db/migrate_test.go (TestMigrations_DryRun): walks every
pending *.up.sql in numeric order, applies each inside its own
BEGIN..ROLLBACK transaction, fails on the first SQL error with the
file name + Postgres error. "Pending" = greater than the scratch
DB's current tracker version, so fresh-DB CI runs verify everything
while developer scratch DBs only re-verify the new pending migration.
Always non-destructive — the rollback runs even on success.
- cmd/server/main_smoke_test.go (TestBootSmoke): boots the apply path
end-to-end, asserts (a) db.ApplyMigrations returns nil, (b) the
tracker advanced to the highest *.up.sql version on disk with
dirty=false, (c) GET /healthz on the registered mux returns 200.
The dry-run catches per-migration syntax errors; this catches the
apply+bind path the container actually runs.
- internal/handlers/handlers.go: adds a GET /healthz public route — a
no-auth, no-DB liveness probe. Used by the boot smoke; also safe
for any future orchestrator or uptime check.
Both live-DB tests gate on TEST_DATABASE_URL and skip cleanly without
it, matching the rest of paliad's live-DB test pattern.
Verification: go build ./... clean, go vet ./... clean,
go test -short ./internal/... ./cmd/... clean (all packages pass,
live-DB tests skip), bun run build clean (2436 i18n keys unchanged).
Per CLAUDE.md inventor → coder gate, NOT self-merged.
m's observation 2026-05-18 (interactive session): toggling "Mit Nichtig-
keitswiderklage" surfaces the response rules (def_to_ccr, reply, rejoin,
…) but the triggering event itself — the act of filing the CCR — is
invisible. Per R.25 VerfO the CCR is filed AS PART OF the Statement of
Defence with the same 3-month deadline, so the corpus author (mig 028)
skipped it. UX problem: users see consequences without the cause.
**New rule** `upc.inf.cfi.ccr`:
- parent: `upc.inf.cfi.soc` (root anchor, same as SoD)
- duration: 3 months (same as SoD — no separate deadline)
- party: defendant
- legal_source: `UPC.RoP.25.1`
- condition_expr: `{"flag":"with_ccr"}`
- priority: **`informational`** — renders as a notice card, no save
action, no duplicate write into paliad.deadlines (the SoD's row
already covers the calendar date).
**Sequence reshuffle** — inserting at sequence_order=11 pushes
def_to_ccr 11→12 and app_to_amend 12→13 so the timeline reads
SoD → CCR → def_to_ccr → app_to_amend (cause before effect).
**Idempotency** — INSERT uses NOT EXISTS keyed on
(proceeding_type_id, submission_code, lifecycle_state='published');
UPDATEs are guarded by the source sequence_order so re-apply is a
no-op. audit_reason set via set_config('paliad.audit_reason', ...,
true) at the top per the mig 099 hotfix pattern.
Migration counter re-checked against origin/main + ls
internal/db/migrations/ | tail before picking 100 — per the friction
note from msg 2016.
Build hygiene: go build/vet clean; bun run build clean (no i18n
changes). Down.sql restores both sequence values + DELETEs the new
row. Branch: mai/fermi/interactive-session.
Mig 099 (drop_with_po_flag) crash-looped paliad.de prod immediately
after deploy: the mig 079 trigger on paliad.deadline_rules raises
EXCEPTION 'audit reason required' on UPDATE when paliad.audit_reason
is unset. Original file (fermi, t-paliad-207) only had the UPDATE,
no set_config wrapper.
Patch: prepend the standard 'SELECT set_config(paliad.audit_reason,
...)' at the top so the trigger sees the reason. Same shape as every
other migration that mutates deadline_rules.
Manual recovery already applied via head MCP — UPDATE'd the 2 rows
with audit_reason set, marked tracker version=99 dirty=false,
force-restarted the container which booted clean. This commit aligns
the in-repo file with the recovered prod state. Idempotent: the
WHERE clause matches only rows that still carry with_po, so re-apply
is a no-op.
m's correction 2026-05-18: the R.19 Einspruch (preliminary objection)
should not be flag-gated. It's an always-available optional submission
the defendant can make once the SoC is served — same logic as the
appeal-spawn rules in t-paliad-203 F2.3 ("the appeal is always a
possibility"). Removing the gate makes the row a normal optional rule:
priority='optional' (unchanged, set by mig 095) gives the save-modal
the existing pre-uncheck behaviour without a separate checkbox.
**Migration 098** (idempotent): NULLs condition_expr on the two RoP.019.1
rows pinned by proceeding code (`upc.inf.cfi` + `upc.rev.cfi`). Re-apply
is a no-op via the WHERE clause matching the live shape. Live DB row
state will sync when Dokploy applies the migration on next deploy — no
raw prod-write this turn (lesson from the previous shift's friction note).
**Frontend cleanup** — removes the two flag rows added to
verfahrensablauf.tsx + fristenrechner.tsx in the parent t-paliad-207
commit (inf-po-flag-row, rev-po-flag-row), the readFlags()/calculate()
push branches, the syncFlagRows() show/hide entries, and the change
listeners. Drops the 4 i18n keys (deadlines.flag.inf_po + rev_po,
DE + EN). Bun build clean: 2417 keys (was 2419, -2 keys × 2 langs).
Branch: mai/fermi/interactive-session @ third commit on top of Path A.
Recovery during the prod outage uncovered a second mig 098 bug: §6.2
assertion '0 NULL submission_code on active+published rows' counted
the 77 orphan rules (proceeding_type_id IS NULL, cross-cutting
Wiedereinsetzung / Schriftsatznachreichung pattern) and rejected the
migration. Patch: gate the NULL count on `proceeding_type_id IS NOT
NULL` so orphans pass through. Migration already applied to prod via
manual recovery with the same patched assertion; this commit aligns
the in-repo file with the deployed state.
Mig 098 (t-paliad-209, ohm) crash-looped paliad.de prod for ~2h: §6.1
assertion regex `^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$` rejects
EPA rule codes that carry the statutory rule number in the suffix —
e.g. `epa.opp.boa.r106`, `epa.grant.exa.r71_3`, `epa.opp.opd.r116`,
`epa.opp.opd.r79_further`, `epa.opp.boa.entsch2`, `epa.opp.boa.r116`.
Migration's UPDATE step succeeds against these rows; the transactional
assertion blows them up; rollback leaves the migration tracker dirty
at version 98 and the container refuses to start.
Patch: allow `[a-z_0-9]` per segment instead of `[a-z_]` in both the
SQL assertion (mig 098 §6.1) and the matching Go shape regex
(submission_codes_shape_test.go). Same change in both spots so the
runtime sanity test stays aligned with the SQL invariant.
Manual recovery already applied: forced
`paliad.paliad_schema_migrations.version` back to 97 with `dirty=false`
so the next deploy retries mig 098 from scratch against the patched
file. No data state changed (mig 098 ran inside a transaction and
fully rolled back — snapshot table, prefix UPDATE, and column rename
all reverted).
go build ./... clean. TestProceedingCodeShapeRegexStandalone green.
m's 2026-05-18 call (workstream B): the paliad.deadline_rules.code field
is a SUBMISSION identifier (the filing/event within a proceeding), not
the legal-citation rule code (which lives in rule_code / legal_source).
Two cleanups land in this migration:
1. DATA — prefix every existing submission code with its proceeding
code so submission codes carry the full hierarchical shape:
inf.soc (on upc.inf.cfi) → upc.inf.cfi.soc
de_inf.klage (on de.inf.lg) → de.inf.lg.klage
de_inf_bgh.revision (on de.inf.bgh) → de.inf.bgh.revision
Idempotent: WHERE NOT LIKE pt.code || '.%' skips already-prefixed
rows so re-running is a no-op.
2. SCHEMA — rename paliad.deadline_rules.code → submission_code so
future devs don't conflate it with rule_code (legal citation) or
proceeding_types.code. The rename is guarded by a column-existence
check, idempotent on a second run.
Drops + recreates the deadline_search materialized view because its
SELECT bakes `dr.code AS rule_local_code` (mig 051 §4); the rebuild
sources from `dr.submission_code` and reproduces every index from mig
051 verbatim.
Backup snapshot table paliad.deadline_rules_pre_098 captures the rows
before the prefix step; serves as the audit anchor and the down's
source.
Hard assertions (§6) gate the migration on:
- every active+published row matches the 4+-segment proceeding-prefixed
shape regex
- no NULL submission_code on active+published rows
- the column was actually renamed
19 active fristenrechner codes renamed from UPPER_SNAKE to the
lowercase three-position dot-separated taxonomy ratified by m on
2026-05-18 (see docs/design-proceeding-code-taxonomy-2026-05-18.md).
IDs are stable; only the `code` STRING changes.
Adds upc.ccr.cfi as an illustrative peer of upc.inf.cfi
(is_active=true, no rules — Go code routes cascade hits back to
inf.cfi with with_ccr=true).
Also updates the soft `proceeding_type_code` references on
paliad.event_category_concepts so the soft-join through
proceeding_types.code keeps resolving, refreshes the
deadline_search materialized view, and installs the
paliad_proceeding_code_shape CHECK constraint enforcing
`^[a-z]+\\.[a-z]+\\.[a-z]+$` on every active row.
Idempotent: every UPDATE is guarded on the OLD code; INSERT uses
WHERE NOT EXISTS; CHECK is dropped-then-recreated by name. Backup
snapshot lives in paliad.proceeding_types_pre_096. Dry-run on the
live youpc DB (BEGIN; … ROLLBACK) confirmed 20 active rows on the
new shape, 0 old codes left, 1 active upc.ccr.cfi.
Codifies curie's 4 new rules + 4 patches from
docs/proposals/fristen-gap-fill-2026-05-18.md § 0.3 (m's decisions).
NEW (4):
inf.prelim UPC_INF parent=inf.soc 1mo RoP.019.1 flag=with_po
rev.prelim UPC_REV parent=rev.app 1mo RoP.019.1 flag=with_po
inf.appeal_spawn UPC_INF parent=inf.decision 2mo RoP.220.1.a always-fire → UPC_APP
rev.appeal_spawn UPC_REV parent=rev.decision 2mo RoP.220.1.a always-fire → UPC_APP
PATCH (4):
de_inf.klage legal_source NULL → 'DE.ZPO.253'
de_inf.anzeige no change (already correct — explicit in audit log)
de_inf.erwidg is_court_set false → true + §276 Abs.1 S.2 description
de_inf.berufung defensive verify legal_source = 'DE.ZPO.517'
Idempotent via WHERE NOT EXISTS (no unique index on (proceeding_type_id,
code) — mig 093 left archived rows sharing codes with their published
successors, so ON CONFLICT isn't available). UPDATEs guarded by clauses
that only fire when the row still has the old value.
Backup snapshot in paliad.deadline_rules_pre_095 (CREATE TABLE IF NOT
EXISTS); down migration restores from it. Hard assertions verify all 4
new rules landed active+published, de_inf.erwidg flipped to court-set,
both spawn rules chain to a valid proceeding_type id=11.
Dry-run verified end-to-end against the live Supabase corpus inside
BEGIN/ROLLBACK; idempotency confirmed by running INSERT+UPDATE twice
in the same transaction.