Adds .gitea/workflows/test.yaml that gates every push on `go build`,
`bun run build`, `go vet`, the migration coordination check, and the
role-split end-to-end migration smoke. On push to main + green, calls
Dokploy's compose.deploy API and polls /health/ready until 200.
t-paliad-282 / m/paliad#114. Design: docs/design-cicd-pre-deploy-gate-2026-05-25.md
(inventor shift on mai/cronus/inventor-ci-cd-pre).
Catches all three of today's outage classes:
brunel (~13:20) slot collision -> TestMigrations_NoDuplicateSlot
hermes (~16:05) dropped-col refs -> TestBootSmoke
mig 129 (~14:56) 42501 ownership -> TestMigrations_EndToEndAsAppRole
Snapshot approach. internal/db/testdata/prod-snapshot.sql is a pg_dump
of youpc-supabase paliad schema + applied_migrations rows. CI restores
this into a fresh `supabase/postgres:15.8.1.060` (same image, same role
topology as prod) and runs ApplyMigrations as the `postgres` role
(which is NOT a superuser on supabase/postgres, matching prod). Existing
migrations are skipped (already in applied_migrations); only NEW migs
from the PR run end-to-end. This sidesteps the fresh-DB idempotence
debt in some historical migrations (mig 037 missing pg_trgm, mig 051
inner COMMIT) — those are tracked separately and don't block the gate.
Sub-changes:
- internal/handlers/handlers.go — new /health/ready endpoint distinct
from /healthz. /healthz stays liveness (process alive, no DB); /ready
is readiness (DB pool pings within 2 s). Returns 503 when svc or pool
is nil (DB-less deploys are intentionally not-ready). svc.Pool added
to handlers.Services, wired in cmd/server/main.go.
- internal/db/migrate_test.go — TestMigrations_NoDuplicateSlot (pure
unit, catches brunel) and TestMigrations_EndToEndAsAppRole (snapshot-
gated, catches the 42501 class).
- cmd/server/main_smoke_test.go — TestBootSmoke now also asserts
/health/ready returns 503 with a nil svc. New TestHealthReady_Live
asserts 200 against a live pool.
- internal/db/migrations/024_rename_department_columns.up.sql and
027_rename_to_partner_units.up.sql — ALTER INDEX / ALTER POLICY
exception handlers now catch undefined_object OR undefined_table OR
duplicate_object. Old handler only caught undefined_object; Postgres
raises undefined_table when source object never existed, and
duplicate_object when destination already exists. The expanded
handlers make these migrations truly idempotent across all plausible
starting states.
- Makefile — verify-mig-app, test-frontend, refresh-snapshot targets.
refresh-snapshot pg_dumps youpc-supabase prod (needs PALIAD_PROD_DATABASE_URL),
strips pg16 \restrict commands for pg15 restore compat, and filters
applied_migrations rows to this branch's max on-disk version.
- internal/db/testdata/README.md — explains the snapshot's purpose,
refresh procedure, and how to verify locally.
- docs/cicd-runner-setup-2026-05-25.md — one-time admin steps for
registering a Gitea Actions runner on mriver and wiring DOKPLOY_TOKEN
as a repo secret. Documents soft-launch plan per m's Q11.4 (keep
Dokploy's autoDeploy=true webhook alive for one week, disable after
the workflow has gated 5 successful deploys).
Build clean. Full go test ./internal/... ./cmd/... green without
TEST_DATABASE_URL. With TEST_DATABASE_URL + TEST_APP_DATABASE_URL set
to a supabase/postgres scratch + snapshot restored:
TestMigrations_NoDuplicateSlot, TestMigrations_EndToEndAsAppRole,
TestBootSmoke, TestHealthReady_Live all pass. Live-DB service tests in
internal/services/* fail under supabase/postgres 15.8 with a 42P08
parameter-binding error (unrelated to Slice A — tracked as a follow-up).
Add 12 Tier 1 procedural deadline rules from curie's audit §10
(docs/research-deadlines-completeness-2026-05-25.md), backfill the
UPC R.104/R.105 Interim Conference citation on upc.inf.cfi.interim
(m/paliad#116 / m's 2026-05-25 report), and fold in the audit Q6
cleanup of the 40 _archived_litigation.* rows.
New rules:
T1.1 upc.inf.cfi.cmo_review 15d / R.333.2
T1.2 upc.inf.cfi.confidentiality_response 14d / R.262.2 (trigger 25)
T1.3 upc.apl.order.grounds_orders 15d / R.224.2(b)
T1.4 upc.apl.order.response_orders 15d / R.235.2
T1.5 upc.inf.cfi.cons_orders 2mo / R.118.4
T1.6 upc.inf.cfi.rectification 1mo / R.353
T1.7 upc.pi.cfi.deficiency 14d / R.207.6(a)
T1.8 upc.pi.cfi.merits_start 31d OR 20wd (max) / R.213 + R.198.1
T1.9 upc.inf.cfi.translation_request 1mo BEFORE oral / R.109.1
T1.10 upc.inf.cfi.interpreter_cost 2wk BEFORE oral / R.109.4
T1.11 upc.inf.cfi.translations_lodge 2wk / R.109.5 (trigger 113)
T1.12 upc.pi.cfi.response UPDATE: re-anchor on .app, court-set
T1.8 uses Wave 2 Slice A primitives (mig 128: working_days unit +
combine_op='max'). T1.9/T1.10 use timing='before' with the
backward-snap path in deadline_calculator.go.
Also drops the deadline_rule_audit.rule_id FK constraint. The mig 079
audit trigger had a latent bug — it could not log DELETEs because the
FK rejected the post-delete INSERT (count(*) WHERE action='delete'
was 0 across the entire history). Audit tables are append-only
history and should not FK-constrain on live entity tables; before_json
preserves the full row state. Unblocking this also unblocks the §13b
Q6 cleanup.
Verified on Supabase: 13 rows present in post-fix shape, all
assertions in the DO-block pass, audit log now records 11 creates +
2 updates + 40 deletes for this migration.
Adds the post-submission court phase to upc.dmgs.cfi and the appeal
route to upc.pi.cfi. The Verfahrensablauf timeline currently stops at
the last party submission (dmgs.rejoin / pi.order); without these rows
the interim conference / oral hearing / decision / appeal sub-tree
never renders, even though atlas's #96 spawn mechanism is in place.
Migration 133 (single slot, coordinated with knuth's #116 on 132):
Section A — UPC Damages tree end (#117):
- upc.dmgs.cfi.interim court-set, R.105
- upc.dmgs.cfi.oral court-set, R.118 / R.250
- upc.dmgs.cfi.decision court-set, R.118 / R.144
- upc.dmgs.cfi.appeal_spawn 2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits
Section B — UPC PI appeal route (#118):
- upc.pi.cfi.appeal_spawn 2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits
PI orders under R.211 dispose of the urgent question and ride the
main 2-month track; the 15-day R.220.1(c) order track does not apply.
Same shape as mig 095 inf.appeal_spawn and the upc.inf.cfi
interim/oral/decision rows from mig 012. Court-set rows reuse the
shared interim-conference / oral-hearing / decision concepts.
Citations: docs/research-deadlines-completeness-2026-05-25.md §D + Tier 4 (R.144), docs/audit-upc-rop-deadlines-2026-05-08.md §D R.144 + §F R.220.1(a)/R.224.1(a). Per-row RoP citation in the migration header.
Idempotent INSERT NOT EXISTS guards per row + post-insert DO block that RAISEs EXCEPTION if any expected row is missing or the spawn shape (is_spawn / spawn_proceeding_type_id / parent_id) is wrong.
go build ./... clean, go test ./internal/... clean, bun run build clean.
Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).
Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
preserving every legacy draft's behaviour byte-for-byte.
Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
uses it when set; falls back to user.Lang otherwise — Slice 1's
format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
outside {de,en}. Project-scoped + global PATCH endpoints both
surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
predecessor. Returns the matched tier (per_code_lang / per_code /
skeleton_lang / skeleton / letterhead) so the editor knows whether
to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
alongside the DE one; per-code EN variants land in a parallel
submissionTemplateENRegistry (empty for now — EN templates land per
HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
`?language=de|en` query override (one-shot path, no draft row to
pull the column from); defaults to the user's UI lang.
Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
Switching the radio PATCHes `language` and the server returns the
freshly-resolved bag + preview HTML so the lawyer sees EN values
immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
sprachspezifische Vorlage)") shows when the resolved tier doesn't
match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.
Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.
Build hygiene: go build/vet/test clean; bun run build clean.
Multi-select party picker on the dedicated submission draft editor —
lawyer picks which of the project's parties to mention in this
specific submission. Adds the t-paliad-277 variable-bag multi-party
shape ({{parties.claimants}}, {{parties.claimant.0.name}}) while
keeping the legacy flat aliases ({{parties.claimant.name}}) for every
existing .docx template authored before the rename.
Surfaces an explicit "Aus Projekt importieren" button + last-imported
timestamp at the top of the variable sidebar so the lawyer can re-pull
project-derived variables (project.*, parties.*, deadline.*,
procedural_event.*, rule.*) when the project data drifts away from the
saved draft overrides. firm.*, today.*, user.* overrides survive the
import — those values aren't sourced from the project record.
Schema: mig 131 adds two columns to paliad.submission_drafts:
- selected_parties uuid[] DEFAULT '{}'::uuid[]
Empty = include every party (legacy default).
Non-empty = restrict to the subset, grouped by role at substitution.
- last_imported_at timestamptz NULL
Bumped each "Aus Projekt importieren" click; surfaced in UI.
Backend:
- SubmissionVarsContext gains SelectedParties — filterPartiesBySelection
restricts the resolved bag before role bucketing.
- addPartyVars emits THREE coexisting forms per role: comma-joined
(parties.claimants), indexed (parties.claimant.0.name), and flat
legacy (parties.claimant.name → first selected claimant). Flat
aliases are kept forever per the issue's backward-compat contract.
- SubmissionDraftService.ImportFromProject strips overrides for
project-derived prefixes and bumps last_imported_at; rejects
project-less drafts (nothing to import from).
- New endpoint POST /api/submission-drafts/{id}/import-from-project.
- DraftPatch + PATCH handlers accept selected_parties.
- submissionDraftView now ships available_parties so the editor can
render the picker without an extra round-trip.
Frontend:
- submission-draft.tsx: new import-row + parties block in the sidebar.
- client/submission-draft.ts: paintImportRow / paintPartyPicker /
onPartySelectionChange / onImportFromProject; group parties by
role bucket (claimant / defendant / other) with DE+EN role-string
matching to mirror the backend bucketing.
- 3 new i18n keys (DE+EN): import.button, parties.title, parties.hint.
- CSS for the picker + import row in global.css.
Tests: 6 new unit tests in submission_vars_parties_test.go covering
the multi-party bag emission, German role-string bucketing, flat-alias
first-of-role resolution, empty-selection-means-all default, non-empty
restriction, and the isProjectDerivedKey policy that powers the
import path.
Build hygiene: go build/vet clean; go test -short ./internal/... pass;
bun run build clean (2876 i18n keys, scan clean).
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.