- Drop the Praxisgruppe field from the onboarding form. Every Paliad user
is in patent practice, so the field carried no signal. The DB column is
retained for future use (set to NULL on insert).
- Switch role from a 4-value enum (partner/associate/pa/admin) to free
text with a <datalist> of suggestions (Partner, Associate, PA, Of
Counsel, Referendar/in, Trainee, wiss. Mitarbeiter/in, Sekretariat).
German firms have many roles beyond the original four.
- Add an optional Dezernat field — the team led by a specific partner.
Free text, no FK (the partner may not be registered yet).
Backend:
- Migration 015: drop the role enum CHECK, replace with non-empty CHECK;
ADD COLUMN dezernat text.
- UserService.Create: drop validRoles map, require non-empty role string,
trim and persist Dezernat. Admin bootstrap gate unchanged.
- models.User gains Dezernat *string; userColumns SELECT updated so
/api/me returns it.
Frontend:
- onboarding.tsx: replace role <select> with <input list=...>; add
dezernat input; remove practice_group.
- onboarding.ts: send dezernat (if non-empty), require role.
- i18n: add onboarding.role.placeholder, onboarding.dezernat[.placeholder],
onboarding.error.role; remove the role.* enum and practice_group keys.
New users were stuck on the dashboard with a dead-end "Bitte schließen Sie das
Onboarding ab" message because nothing created the paliad.users row that all
matter-management features depend on. This adds the missing Phase D flow.
Backend
- UserService.Create: validates display_name / office / role, inserts the
paliad.users row with (id, email) from the verified JWT claims (never from
the request body — prevents onboarding as someone else).
- Admin bootstrap: only the very first paliad.users row may self-assign
role='admin'; subsequent requests get ErrAdminBootstrapOnly (403). Guarded
by pg_advisory_xact_lock so two concurrent first-logins can't race past
the count=0 check under READ COMMITTED.
- POST /api/onboarding + GET /onboarding; the page is authenticated but NOT
behind the onboarding gate (it's the one page users without a paliad.users
row may reach).
- gateOnboarded middleware wraps the matter-management pages (Dashboard,
Akten, Fristen, Termine, Einstellungen/CalDAV) and 302s to /onboarding
when the caller has no paliad.users row. Knowledge-platform pages
(Kostenrechner, Glossar, Links, Downloads, Gerichte, Gebührentabellen,
Checklisten, Fristenrechner) stay ungated.
- auth.VerifiedClaims now carries the email claim; auth.ClaimsFromContext
exposes it to handlers. GET /api/me includes the email in the 404 body so
the onboarding form can pre-fill the display name from the local-part.
Frontend
- frontend/src/onboarding.tsx + src/client/onboarding.ts: centred card on the
existing .login-card styling. Fields: display_name (required, pre-filled
from email local-part), office (dropdown from /api/offices), role
(dropdown, default associate), practice_group (optional).
- Dashboard client: toggleOnboardingHint now redirects to /onboarding
instead of showing the dead-end hint — belt-and-braces behind the server
gate in case the DB lookup fell through.
- DE + EN i18n keys for every label, placeholder, and error.
- Added onboarding to build.ts.
Tests: internal/services/user_service_test.go covers the valid path,
per-field validation, duplicate (ErrUserAlreadyOnboarded), and the
admin-bootstrap gate. Follows the existing TEST_DATABASE_URL skip pattern.
Three items from docs/improvement-audit.md §2:
I-5 Verlauf pagination
- AkteService.ListEvents now accepts a (before *uuid.UUID, limit int) cursor
- SQL uses a composite (created_at, id) cursor subquery — stable across
rows written in the same microsecond
- Handler parses ?before=<uuid>&limit=<n>, service clamps to 200
- Frontend fetches first page (50) on init and exposes a "Mehr laden" /
"Load more" button that keeps paging until the tail returns < page size
- i18n keys akten.detail.verlauf.loadMore / .loadingMore in DE + EN
I-8 patholo → paliad client-side rename with migrations
- i18n.ts: STORAGE_KEY is now paliad-lang; one-shot migration reads the
old patholo-lang value, writes the new key, deletes the old
- sidebar.ts: same pattern for paliad-sidebar-pinned
- Cookie rename with dual-read grace period: SessionCookieName is
paliad_session, LegacySessionCookieName keeps patholo_session as
read-only fallback. Requests using the legacy cookie get upgraded to
paliad_session in the response; legacy cookie is expired in the same
response. ClearAuthCookies clears both names to prevent stale-cookie
resurrection. Remove the legacy fallback after 2026-05-18 (30d cookie
max age).
- handlers/links.go:extractEmailFromCookie reads either cookie name via
auth.SessionCookieName / auth.LegacySessionCookieName
P-6 Single source of truth for offices
- New internal/offices package: Office struct + All + IsValid + Keys
- akte_service.go switched from inline isValidOffice to offices.IsValid
- GET /api/offices returns the list with DE + EN labels
- Akte create form (akten-neu.tsx) has an empty <select>; the client TS
fetches /api/offices and populates options, re-rendering on lang change
Tests:
- internal/offices/offices_test.go covers IsValid + Keys + label coverage
- internal/auth: three new Middleware tests — legacy cookie still
authenticates + upgrades the browser, new cookie wins when both are
present (no clobber), missing cookie returns 401 on API paths
Build: go build ./... + go vet ./... + go test ./... + bun run build all clean.
Known out-of-scope: handlers/links.go still POSTs to public.patholo_link_*
via PostgREST; migration 011 created fresh paliad.link_* tables but the
handler refactor (move to direct DB, copy data, drop public tables) is a
separate phase documented in that migration's header.
Items from docs/improvement-audit.md §2 + §3:
I-1 Hide Dokumente tab entirely from Akten detail (Phase H deferred);
drop placeholder TSX panel, VALID_TABS entry, and orphaned
akten.detail.soon.* i18n keys.
I-2 Add data-i18n keys for all 7 office labels on the landing page.
EN mode now correctly renders "Milan" (was "Mailand").
I-3 Unify UPC URLs in Gerichtsverzeichnis to the canonical hyphenated
form (unified-patent-court.org) matching links.go — 43 occurrences.
I-6 Add SEP/FRAND glossary category with 13 entries (FRAND, SEP,
Standard-essentielles Patent, Patentpool, Anti-Suit, Anti-Anti-Suit,
Injunction Gap, Orange-Book-Standard, Huawei/ZTE, RAND, ETSI IPR,
Patent-Hold-up, Patent-Hold-out) + filter pill + suggest-modal option.
I-7 Refresh README: list migration 014 (checklist_instances), mark
Phase I (Notizen) and Phase J (docs) shipped.
P-1 Remove HL Intern stub links (URL "#") and the now-empty "hl" category.
P-2 Dashboard heading: "Meine Mandate" → "Meine Akten" (matches CLAUDE.md
naming convention). Onboarding hint updated likewise.
P-4 Drop "Hogan Lovells Patent Practice" from the footer — Paliad is the
firm-agnostic brand.
P-5 Empty-state text on Fristen- and Termine-Kalender when the viewed
month has no items.
Verified: bun run build clean, go build / vet / test ./... clean.
C-1. Session JWT signature verification (authZ bypass fix)
- Add SUPABASE_JWT_SECRET env var; fail-fast at startup if unset.
- auth.Client.VerifyToken uses github.com/golang-jwt/jwt/v5 to verify
HS256 signatures, reject alg=none, enforce exp/nbf/iat.
- Middleware stores verified claims in request context; WithUserID
reads only verified claims (no more raw-cookie sub decoding).
- API requests get 401 on missing/invalid token (was 302 redirect).
- Refresh flow only runs on expiry; other signature failures reject
outright and clear cookies.
C-2. Dashboard Termine cross-user privacy leak
- dashboard_service.loadUpcomingAppointments now mirrors
TerminService.canSee: personal Termine (akte_id IS NULL) are
creator-only; admins do NOT see other users' personal Termine.
C-3. Role gate on Parteien + Termine mutations
- ParteienService.Delete now partner/admin only (matches FristService).
- TerminService.Update / Delete on Akte-linked Termine now require
partner/admin (or the original creator). Personal Termine stay
creator-only.
C-4. Email gate → ALLOWED_EMAIL_DOMAINS whitelist
- isHoganLovellsEmail → isAllowedEmailDomain reading the env var
(default: hoganlovells.com,hlc.com,hlc.de). Case-insensitive,
whitespace-tolerant.
- login.tsx placeholder: name@hoganlovells.com → name@hlc.com
- Error strings + login.hint (de/en) rewritten for HLC branding.
C-5. Docker compose env wiring
- docker-compose.yml gains SUPABASE_JWT_SECRET, CALDAV_ENCRYPTION_KEY,
and ALLOWED_EMAIL_DOMAINS passthrough; commented-out
ANTHROPIC_API_KEY line for Phase H readiness.
Tests
- auth_test.go: valid/wrong-secret/expired/alg-none/missing-sub/garbage
token cases for VerifyToken.
- handlers/auth_test.go: default + env-override cases for the email
whitelist.
- go build ./..., go vet ./..., go test ./... all clean.
Post-Phase-A–J full-product audit: UX, code, content, architecture,
ops. 5 Critical findings (JWT signature bypass, dashboard Termine
leak, parteien/termin delete policy gap, @hoganlovells-only email
gate, CALDAV_ENCRYPTION_KEY missing from compose), 8 Important, 10
Polish, 11 Feature ideas, 14 tech-debt items. Each item has a file
reference and a concrete fix.
Top-two exploit-paths (detailed in §1):
1. internal/auth/auth.go:178 — middleware decodes JWT exp but never
verifies the signature; sub-claim is trusted downstream by every
service. Any authenticated cookie → impersonate any user.
2. internal/services/dashboard_service.go:245 — personal Termine
leaked cross-user on the /dashboard "Kommende Termine" list
(missing created_by filter on the akte_id IS NULL branch).
Checklisten move from one-per-slug localStorage state to a template/instance
model. A user creates multiple named instances of each template (UPC SoC,
EPA Einspruch, …), each with its own checkbox state in paliad.checklist_instances
and an optional akte_id for office-wide visibility.
- Migration 014: paliad.checklist_instances + RLS mirroring the Termine
pattern (akte_id nullable → creator-only; akte_id set → can_see_akte gate).
- Static template data moves out of internal/handlers into internal/checklisten
so both handlers and the new ChecklistInstanceService can reference it
without an import cycle.
- ChecklistInstanceService: CRUD + state merge via `state || $n::jsonb`
so concurrent checkbox toggles don't clobber each other. Reset clears
state to {}. Akte-linked mutations append akten_events audit rows.
- Handlers: GET/POST /api/checklisten/{slug}/instances, GET/PATCH/DELETE
/api/checklisten/instances/{id}, POST .../reset, GET /api/akten/{id}/checklisten.
- /checklisten/{slug} redesigned to show template metadata + instance
table + "Neue Instanz" modal (with optional Akte dropdown). The
interactive checkboxes move to /checklisten/instances/{id} where the
state is DB-backed and Reset posts to the server. Fixes the original
Reset button regression — it now operates on real server state rather
than silently failing client-side.
- Akten detail grows a Checklisten tab listing linked instances with
progress bars; only loads on tab activation.
- localStorage-based progress removed from the overview grid (state no
longer lives there).
- DE + EN i18n keys added.
Verified: bun run build clean; go build ./...; go vet ./...; go test ./...
all green.
Polymorphic notes attached to Akten, Fristen, Termine, or AktenEvents.
Schema (paliad.notizen + paliad.notiz_is_visible) shipped with Phase A
migrations; this phase adds the service, handlers, and shared UI.
Backend
- NotizService (internal/services/notiz_service.go): ListForAkte /
ListForFrist / ListForTermin / ListForAktenEvent + Create / Update /
Delete. Visibility resolves through the parent row — AkteService.GetByID
for Akte/Frist/AktenEvent parents, TerminService.GetByID for Termin
parents (personal Termine are creator-only).
- Edit restricted to the original author; delete allows author +
partner/admin. Create on an Akte-scoped parent appends an akten_events
"notiz_created" audit row in the same transaction; personal Termin
notes skip the audit.
- Author join (paliad.users) surfaces display_name + email on every
listed note so the client can render "von <Name>" without per-row
/api/users fetches.
- Routes wired in handlers.go: GET/POST /api/akten|fristen|termine/{id}/
notizen, PATCH/DELETE /api/notizen/{id}.
Frontend
- Shared client module frontend/src/client/notizen.ts exposes
initNotes(container, parentType, parentId). Renders an add-note form,
list of note cards with relative timestamps (gerade eben / vor N
Minuten / gestern / …), edit + delete affordances gated by author/
role, optimistic add/edit/delete with rollback on error, Ctrl+Enter
submit, and URL auto-linkification inside sanitised note bodies.
- Integrated into akten-detail (Notizen tab — placeholder replaced),
fristen-detail (new "Notizen" section below the detail list), and
termine-detail (new "Notizen" section above the edit form).
- DE + EN i18n keys added; obsolete akten.detail.soon.notizen placeholder
keys removed.
- Notiz-card styles added to global.css (accent-coloured focus, hover
actions, relative-time colour) matching the existing Verlauf card look.
Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.
Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
Termine follow AkteService.GetByID. Every Akte-attached mutation appends
an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
to start with malformed key; unset key leaves CalDAV disabled and all
/api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
/api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.
Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
test-connection button, status card, sync log table, delete button that
purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.
Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password
t-paliad-010
Adds the persistent-deadline layer on top of the Phase A schema:
Backend (Go)
- internal/services/frist_service.go: CRUD + bulk import + summary
counts, all gated through AkteService.GetByID for office-scoped
visibility. Every mutation writes an akten_events row.
- internal/handlers/fristen.go: GET/POST/PATCH/DELETE for /api/fristen,
/api/fristen/{id}, /api/fristen/{id}/complete, /api/fristen/summary,
/api/akten/{id}/fristen, /api/akten/{id}/fristen/bulk.
- internal/handlers/fristen_pages.go: serves the four new HTML pages.
- Models: Frist + FristWithAkte (joined for the list page).
- Service wired into cmd/server/main.go.
Frontend (Bun TSX + per-page client TS)
- /fristen — list with traffic-light summary cards (red/amber/
green), status + Akte filters, inline mark-complete.
- /fristen/neu — create form (Akte select, due date, optional rule
+ notes); /akten/{id}/fristen/neu pre-selects.
- /fristen/{id} — detail with inline edit, complete, role-gated delete.
- /fristen/kalender — month grid with deadline dots + day popup.
- Akten detail "Fristen" tab now shows the real list (Phase D
placeholder removed).
- Fristenrechner: "Als Frist(en) speichern" CTA opens a modal that
picks an Akte + which calculated rows to import (POSTs to /bulk).
- Sidebar: activates the Fristen entry (was greyed-out in Phase D).
- DE/EN i18n for all new copy.
- Traffic-light + calendar styles in global.css.
Visibility, audit and role-gating reuse the Phase B/D primitives —
no new RLS or auth surface.
New /dashboard route serves the authenticated home screen with a
server-rendered payload (no skeleton→fetch waterfall, per design
audit §2.3). / now redirects authenticated visitors to /dashboard
and keeps the marketing landing for anonymous visitors.
- DashboardService aggregates deadline + matter summaries, the next
7d of Fristen/Termine, and the last 10 akten_events, all scoped
by the standard office-visibility predicate.
- Dashboard handler splices the JSON payload into dist/dashboard.html
as window.__PALIAD_DASHBOARD__ so the client paints on first frame;
client re-fetches /api/dashboard every 60s to stay current.
- Sidebar gains an "Übersicht" group with the Dashboard entry at the
top; DE/EN i18n keys + traffic-light card styles added.
- Empty-state copy, onboarding hint, and 503 handling keep the page
intact when DATABASE_URL is unset.
- Delete internal/calc/deadlines.go/deadline_rules.go/holidays.go (ported to services)
- fristenrechner handler routes through FristenrechnerService when pool present
- Returns 503 with German message when DATABASE_URL unset (page still renders)
- Migration 012: add name_en columns + seed 9 UI-facing proceeding types
- Commit captures cronus's work after session termination
- TSX pages: list, create form, detail with Verlauf/Parteien tabs +
Fristen/Termine/Dokumente/Notizen placeholders for future phases
- Client TS bundles for each page (search, filter, tab switching, inline
title edit, party add/remove, delete-confirm modal, collaborator picker)
- Sidebar refactored into groups (Arbeit/Werkzeuge/Wissen/Ressourcen);
Akten as first Arbeit entry; Fristen/Termine shown disabled with tooltip
- Backend: /api/me, /api/users, /api/akten/{id}/events + AkteService.ListEvents
- Server routes for /akten, /akten/neu, /akten/{id} and tab sub-routes
- i18n: full DE/EN strings for Akten UI + sidebar groups; title attr support
- Lime CTAs (#c6f41c), office badges, status chips, audit-trail feed
- Office-scoped visibility (firm_wide_visible partner-only, delete
partner/admin-only) gated in UI; backend enforces regardless
- Graceful DATABASE_URL-unset message on list page; no 5xx
Production crash when DATABASE_URL was first set on the shared Supabase:
pq: column "dirty" does not exist at column 17 (42703)
in line 0: SELECT version, dirty FROM "public"."schema_migrations"
Root cause: the Supabase instance already had a differently-shaped
public.schema_migrations (version-only, no dirty column) from another app
or earlier tool. golang-migrate's default tracking table is called
"schema_migrations" and lives in current_schema() (public, since paliad
didn't exist yet at migrator startup). The driver tried to read its own
schema from the foreign table and blew up.
Fix:
1. Set postgres.Config.MigrationsTable = "paliad_schema_migrations" — a
uniquely-named tracker that cannot collide with another app's table.
2. Pre-create the paliad schema before invoking golang-migrate so
subsequent migrations target it cleanly. Idempotent via IF NOT EXISTS.
3. Leave the tracker in `public` (default SchemaName). Rationale: the
first migration's down-step is DROP SCHEMA IF EXISTS paliad CASCADE,
which would take a paliad.schema_migrations tracker with it and break
any subsequent migrate.Up(). Keeping it in public makes down-cycles
safe.
Verified locally:
- Reproduced the collision by creating a public.schema_migrations with
only a version column (matching the production shape) and running the
fixed migrator against it.
- Pre-existing public.schema_migrations untouched (version=42 preserved).
- New public.paliad_schema_migrations created at version=11.
- All 15 paliad.* tables created.
- Idempotent: second migrator run reports ErrNoChange, no double-apply,
seed data unchanged.
- Live tests (TEST_DATABASE_URL) still pass against the collision DB.
Implements docs/design-kanzlai-integration.md §8 Phase B.
Pool & infrastructure:
- internal/db/pool.go — sqlx connection pool via DATABASE_URL
(lazy, sync.Once, returns nil if unset)
- cmd/server/main.go wires pool + services on startup; skips gracefully
if DATABASE_URL unset (existing endpoints still work)
Services (internal/services/):
- holidays.go — ported from KanzlAI. Audit §1.6 fix: replaces unguarded
map with sync.Map of *yearEntry (sync.Once per year), race-safe under
concurrent readers.
- deadline_calculator.go — ported. days/weeks/months + before/after
timing + holiday/weekend adjustment via HolidayService.
- deadline_rule_service.go — ported, DB-backed. List, GetRuleTree,
GetFullTimeline (recursive CTE for cross-type spawns), GetByIDs,
ListProceedingTypes.
- user_service.go — reads paliad.users; GetByID returns (nil, nil) for
users who haven't onboarded yet (safe default = no visibility).
- akte_service.go — new. Office-scoped visibility enforced at the app
layer (defense-in-depth alongside RLS). ListVisibleForUser uses the
visibility predicate directly in SQL so indexes can drive the query.
Create/Update/Delete enforce role gates:
* associates can only create in their own office
* only admins can move an Akte between offices
* only partners/admins can toggle firm_wide_visible
* only partners/admins can delete (soft, status='archived')
Writes an akten_events row on create, status change, firm-wide toggle,
collaborator change.
- parteien_service.go — ported. Visibility inherited from the parent
Akte via AkteService.GetByID gate.
Sentinel errors:
- services.ErrNotVisible → handlers return 404 (never leak existence)
- services.ErrForbidden → 403
- services.ErrInvalidInput → 400
Auth context:
- internal/auth/user.go — WithUserID middleware extracts the `sub` claim
from the Supabase JWT session cookie and injects uuid.UUID into the
request context. Runs after Client.Middleware (which already validated
the cookie expiry). Handlers use auth.UserIDFromContext().
Handlers (internal/handlers/):
- akten.go — full CRUD for /api/akten + /api/akten/{id}/parteien.
All require DB configured (503 otherwise) and authenticated user
(401 otherwise). Returns 404 for non-visible IDs.
- deadline_rules_db.go — GET /api/deadline-rules, GET
/api/proceeding-types-db, POST /api/deadlines/calculate.
The /api/deadlines/calculate endpoint lives alongside the existing
in-memory /api/tools/fristenrechner; Phase C swaps the UI over and
deletes the in-memory rule tree.
- handlers.Register now takes an optional *Services bundle; when
DATABASE_URL unset the DB-backed endpoints return 503 with a clear
error message.
Tests (internal/services/):
- holidays_test.go — Easter algorithm (5 years spot-checked), German
federal holidays, weekend + Neujahr adjustment, concurrent cache
reads under -race.
- deadline_calculator_test.go — days/weeks/months calc, before timing,
Karfreitag→Ostermontag skip (lands on Tue 2026-04-07), batch with
zero-duration rule.
- akte_service_test.go — live DB test behind `TEST_DATABASE_URL` (skip
otherwise). Verifies 4-Akte × 3-user visibility model AND role
enforcement (associate can't delete, can't cross-office-create,
invalid office rejected).
Manual verification:
- `go build ./...` + `go vet ./...` clean
- `go test ./internal/services/ -race` passes (DB tests skip without URL)
- With TEST_DATABASE_URL set, all visibility + role tests pass
- Live HTTP smoke test with forged JWT cookie:
* /api/deadline-rules returns 40 rules
* /api/proceeding-types-db returns 7 types
* /api/deadlines/calculate INF + 2026-04-15 returns calculated deadlines
* /api/akten returns [] (user has no paliad.users row yet — safe default)
* /login, / still work (no regressions)
Implements docs/design-kanzlai-integration.md §8 Phase A.
Schema (paliad.*):
- users (extends auth.users) with office, practice_group, role
- akten with visibility columns: owning_office, collaborators uuid[],
firm_wide_visible (per design §2)
- parteien, fristen, termine, dokumente, akten_events, notizen
(polymorphic notes; notizen_exactly_one_parent CHECK)
- proceeding_types, deadline_rules, holidays (reference data)
- 4 feedback tables re-namespaced from public.* into paliad.*
(handler swap to direct DB is a follow-up; old public tables stay
intact for now and continue serving via PostgREST)
Visibility (paliad.can_see_akte):
- single SQL function, used by every RLS policy
- predicate: firm_wide_visible OR owning_office matches user's office
OR auth.uid() ∈ collaborators OR user is admin
- mirrored at app layer in Phase B (defense in depth)
RLS (real, not permissive):
- akten: visibility predicate; insert restricted to own office or admin;
delete restricted to partners + admins
- parteien/fristen/dokumente/akten_events: inherit via can_see_akte(akte_id)
- termine: personal (akte_id NULL) visible only to creator; Akte-linked
follow visibility predicate
- notizen: paliad.notiz_is_visible() resolves polymorphic parent
- reference tables: SELECT for any authenticated user
- users: SELECT all; UPDATE/INSERT only self
- feedback tables: INSERT for any authenticated user (write-only)
Seed data (ported from KanzlAI seed_upc_timeline.sql):
- 7 proceeding_types (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL)
- 40 deadline_rules (32 UPC + 4 ZPO + 4 cross-type appeal spawns)
including conditional logic: Reply rule code (RoP.029b → 029a) and
Rejoinder duration (1mo → 2mo) flip when CCR active
- 55 holidays (DE federal 2026/2027 + UPC summer 2026 + UPC winter 26/27)
Indexes per audit §3.3 + visibility-predicate hot paths:
- akten: (status, owning_office), (owning_office), partial on
firm_wide_visible, GIN on collaborators
- fristen: (status, due_date), (akte_id)
- termine: (start_at), (akte_id)
- akten_events: (akte_id, created_at DESC)
- notizen: 4 partial indexes per parent type
- users: (office), (role)
Migration tooling:
- golang-migrate/migrate/v4 with embed.FS source
- Migrations live in internal/db/migrations/ (Go embed can't reach
outside the package; this is the conventional Go layout for embedded
migrations)
- Applied at server startup before HTTP listener binds
- DATABASE_URL is optional today (existing knowledge tools work without
DB); becomes required once Phase B services land
- Mock Supabase auth schema for local testing in
internal/db/migrations/_dev/mock_supabase_auth.sql (excluded from
embed pattern by the underscore prefix)
Other changes:
- Dockerfile: bump golang to 1.24, copy go.sum (audit §2.9), rename
binary patholo → paliad
- docker-compose.yml: add DATABASE_URL passthrough
- README.md: rewritten to reflect Paliad brand + Phase A migration system
Verified locally:
- 11 migrations applied cleanly against postgres:16-alpine
- RLS enabled on all 15 paliad.* tables (verified via pg_class.relrowsecurity)
- Visibility predicate verified with 4-case scenario:
- Alice (Munich associate): sees Munich + firm-wide + collab-on (t f t t)
- Bob (Düsseldorf associate): sees Düsseldorf + firm-wide + collab-on (f t t t)
- Carol (Munich partner): sees Munich + firm-wide only (t f t f)
- Anonymous: sees firm-wide only (f f t f)
- migrate down + re-up cycle clean (initial 007 down had ordering bug,
fixed: drop policies before referenced function)
- Existing endpoints (/, /login) return 302 + 200 — no regressions
Five changes from athena's review (paliad/athena → paliad/cronus):
1. §2 rewritten — office-scoped visibility from day one (NOT firm-wide).
- paliad.users adds: office (required), practice_group (optional), role
- paliad.akten adds: owning_office, collaborators uuid[], firm_wide_visible
- SQL function paliad.can_see_akte(akte_id) used by every RLS policy
- Visibility predicate: own office OR collaborator OR firm_wide OR admin
- Real (not permissive) RLS policies enforced from Phase A
- Defense in depth: app-layer ListVisibleForUser mirrors the predicate
- Onboarding flow added (Phase D) so users self-identify office on signup
2. Mandate → Akten throughout (German end-to-end):
- Tables: paliad.akten / parteien / fristen / termine / dokumente /
akten_events / notizen
- Go structs: Akte, Partei, Frist, Termin, Dokument, AkteEvent, Notiz
- URLs: /akten, /akten/[id], /akten/[id]/{verlauf|fristen|...}
- UI: "Akten", "Aktenverwaltung", "Zur Akte speichern" CTA on Fristenrechner
- Naming convention table added in §3
3. §9 risk added: Outlook/Exchange CalDAV is limited; Phase F ships with
CalDAV only (verified against dav.msbls.de + iCloud); long-term plan is
Phase K = EWS / Microsoft Graph backend behind same sync abstraction.
4. Compliance/IT-approval unknown removed from §9 (m handles out of band).
5. Single-tenant risk replaced by visibility-model risk (now the
security-critical layer); Phase A and B both gain Opus design reviews
on the visibility predicate; Phase B integration test requires 3 users
in 2 offices; pre-Phase J pen-test pass added.
Effort: 52h → 56h (Phase A +2h for visibility model, Phase D +2h for
onboarding + collaborator UI). Total with design ~59h, ~2-3 weeks.
The Hogan Lovells merger makes the "HoLo" portmanteau obsolete. Paliad
(patent paladin) is firm-agnostic and survives future firm name changes.
- Page titles, logo/sidebar, footer, kostenrechner PDF branding
- All DE/EN i18n strings in frontend/src/client/i18n.ts
- README product line
Unchanged: repo/module/Go import paths, cookie names, Supabase table
names, localStorage keys, package.json name — all remain "patholo" as
internal identifiers. HL footer reference stays pending the post-merger
domain decision.
41 courts: UPC Court of Appeal, Central Division sections (Paris/Munich/Milan),
13 Local Divisions, Nordic-Baltic Regional Division, 10 German courts
(LG, OLG, BGH, BPatG, DPMA), EPO (Munich HQ, Haar boards, Rijswijk), and
9 national courts (NL, UK, FR, IT). Addresses verified against official
sources; uncertain details left empty rather than guessed.
New page at /gerichte with search, dual filter pills (type + country),
expandable cards, print-friendly CSS, Supabase feedback (gerichte_feedback).
Migration at docs/migrations/002_gerichte_feedback.sql.
Six bilingual patent-workflow checklists (UPC Statement of Claim, Defence,
Confidentiality Application, Representative Registration; BPatG Nullity;
EPO Opposition) with grouped items, rule references, and tips. Index page
lists cards with regime filter and per-checklist progress; detail page
persists check state in localStorage (patholo:checklist:<slug>), shows a
live progress bar, supports reset and print, and submits feedback via
Supabase checklisten_feedback.