# Paliad — Product Audit & Improvement Roadmap

Prepared by `cronus` (inventor) on 2026-04-18 for task **t-paliad-015**.
Scope: complete code, UX, content, architecture, and ops audit after the
17 000-line KanzlAI → Paliad integration (Phases A–J, April 2026).

Audit posture: first-real-user (HLC patent lawyer, Munich) **and**
long-term architect. Items are prioritised and actionable — each has a
file reference and a suggested fix. No hour estimates; effort is
expressed as **S / M / L** (small / medium / large).

- **S** = <1 day of focused work; single file or localised change
- **M** = multi-file change, migration, or non-trivial design
- **L** = new subsystem, cross-cutting rework, or external dep

---

## TL;DR — the two things to fix today

1. **The session middleware does not verify JWT signatures** and the Go
   backend extracts `auth.uid()` from that unverified token to gate every
   database read and write. A forged cookie with any `sub` and a future
   `exp` gives an attacker access to any user's Akten, including admin.
   See **C-1** below. `internal/auth/auth.go:178` +
   `internal/auth/user.go:60`.
2. **The dashboard leaks every user's personal Termine to every other
   logged-in user** for the next 7 days (title, location, description,
   start/end). `internal/services/dashboard_service.go:245`. See **C-2**.

Both are one-file fixes and must ship before this audit reaches a wider
pilot group.

---

## 1 — Critical (fix immediately)

### C-1. Session JWTs are not signature-verified

**File:** `internal/auth/auth.go:178`, `internal/auth/user.go:60`
**Severity:** Critical (authZ bypass)
**Effort:** M

`Client.Middleware` accepts the session cookie, parses `exp` via
`DecodeJWTExpiry`, and calls `next` if the token is unexpired. The
signature is never checked. `WithUserID` then base64-decodes the `sub`
claim straight into the request context. `AkteService.GetByID` and every
other service trusts that UUID as the authenticated user.

Exploit: craft any JWT with `exp` in the future and `sub =
<admin-user-uuid>`. Set it as the `patholo_session` cookie. The Go
backend uses a service-role Postgres connection, so RLS policies do not
run and the app-level visibility check sees the forged UUID as a real
admin. Effectively: anyone with a Paliad cookie can impersonate anyone.

**Fix:**

- Fetch the Supabase project's JWT signing key (JWKS endpoint:
  `${SUPABASE_URL}/auth/v1/.well-known/jwks.json`) or the shared
  `SUPABASE_JWT_SECRET` and verify the token in `Middleware` before
  trusting any claim.
- Cache the JWKS for 1 hour; rotate on kid mismatch.
- `WithUserID` should read the *verified* claims, not re-decode the raw
  cookie.
- Consider `github.com/golang-jwt/jwt/v5` — already idiomatic for Go.

**Related:** `internal/services/akte_service.go:18-24` explicitly
documents that RLS "does not kick in because the backend does not
provide a JWT-backed auth.uid()" — so the app-layer predicate is the
*only* gate. The JWT must therefore be trusted.

---

### C-2. Dashboard leaks every user's personal Termine cross-user

**File:** `internal/services/dashboard_service.go:232-256`
**Severity:** Critical (privacy / confidentiality)
**Effort:** S

```sql
WHERE t.start_at >= $4
  AND t.start_at <  ($4 + interval '7 days')
  AND (t.akte_id IS NULL              -- ← ANY personal Termin, any user
       OR a.firm_wide_visible = true
       OR a.owning_office = $1
       OR $2::uuid = ANY (a.collaborators)
       OR $3 = 'admin')
```

Personal Termine (`akte_id IS NULL`) are creator-only by contract
(`TerminService.canSee` enforces `created_by = userID`). The dashboard
forgets this filter and returns up to 10 such rows for *any* user —
title, location, description, times included.

In practice an associate's "Bewerbungsgespräch bei Kanzlei X" is visible
to partners and vice-versa.

**Fix:** change the personal-Termin branch to
`(t.akte_id IS NULL AND t.created_by = $2::uuid)` — mirrors the already-
correct rule in `termin_service.go:103`.

---

### C-3. Any user with visibility can delete Akte children (Parteien, Termine)

**File:**
- `internal/services/parteien_service.go:93-111` — Delete has no role gate.
- `internal/services/termin_service.go:357-398` — Akte-linked Termine: only
  personal Termine check creator; Akte-linked pass through with just
  visibility.

**Severity:** Critical (data loss; inconsistent with Fristen policy)
**Effort:** S

An associate in London, viewing a Munich firm-wide Akte, can
`DELETE /api/parteien/{id}` and erase the Klägerin-record, or delete any
hearing from the Akte's calendar. `FristService.Delete` already scopes
to `partner|admin` only — the other child services should follow suit.

**Fix:** require `user.Role in {partner, admin}` (or `created_by =
userID`) for delete on `ParteienService` and `TerminService`. Apply the
same rule to `Update` on both.

---

### C-4. Email gate still pins to `@hoganlovells.com`

**File:** `internal/handlers/auth.go:113-116`
**Severity:** Critical (post-merger lockout)
**Effort:** S

HLC email domains (`@hlc.com`, `@hlc.de`) cannot register or log in.
Login-form placeholders still say `name@hoganlovells.com`.

**Fix:**

- Replace `isHoganLovellsEmail` with an env-configurable whitelist,
  default `hoganlovells.com,hlc.com,hlc.de` (design §2 already
  requested this).
- Update placeholders in `frontend/src/login.tsx:26,34` + the
  `login.hint` i18n key (de + en).
- Error strings (`"Zugang nur für @hoganlovells.com …"`) should become
  "… für autorisierte HLC-E-Mail-Adressen."

---

### C-5. `CALDAV_ENCRYPTION_KEY` not wired into production compose

**File:** `docker-compose.yml:4-12`
**Severity:** Critical in effect (feature silently disabled)
**Effort:** S

The compose file declares `SUPABASE_URL`, `SUPABASE_ANON_KEY`,
`GITEA_TOKEN`, `DATABASE_URL`. It does **not** pass
`CALDAV_ENCRYPTION_KEY`. On the Dokploy compose (`Zx147ycurfYagKRl_Zzyo`)
this means `cmd/server/main.go:66` logs
"CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501" and
the entire Termine-sync feature is dead on paliad.de. Users who save
their CalDAV settings see a 501 from `/api/caldav-config` and conclude
the product is broken.

**Fix:** add `- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}` (and
while we're at it, a placeholder `ANTHROPIC_API_KEY` commented out so
Phase H reactivation is one uncomment). Set the actual key on Dokploy.

---

## 2 — Important (fix this week)

### I-1. Dokumente tab on Akten detail is a dead placeholder

**File:** `frontend/src/akten-detail.tsx:215-222`,
`frontend/src/client/i18n.ts:468,1241`
**Severity:** Important (visible UI dead-end; leaks internal phase names)
**Effort:** S

The tab is rendered, clickable, and shows the text "Dokumenten-Upload
folgt in Phase H." — a strong UX signal that the product is unfinished.
Phase H is deferred indefinitely.

**Fix (pick one):**

- Hide the Dokumente tab entirely until there is real content — drop it
  from the VALID_TABS list in `akten-detail.ts:62` and the TSX tabs
  strip.
- Or keep it visible but replace the copy with a neutral "Dokumenten-
  Upload in Planung" (no phase leak) and a discreet CTA to vote/express
  interest (write to `paliad_feature_interest`).

I'd hide it — dead tabs are worse than missing features.

### I-2. Office labels in German not translated to English

**File:** `frontend/src/index.tsx:122-128`
**Severity:** Important (i18n regression visible on landing page)
**Effort:** S

Only `index.munich` has a `data-i18n` key. Düsseldorf, Hamburg,
Amsterdam, London, Paris, **Mailand** render raw German in EN mode.
"Mailand" → "Milan" is the most obvious miss; Düsseldorf/London/Paris
are correct in both languages but the fact that only one is i18n'd is a
consistency bug waiting for the next new office.

**Fix:** add `index.office.munich|duesseldorf|hamburg|amsterdam|london|
paris|milan` keys (de: "München / Düsseldorf / Hamburg / Amsterdam /
London / Paris / Mailand"; en: "Munich / Düsseldorf / Hamburg /
Amsterdam / London / Paris / Milan").

### I-3. Gerichtsverzeichnis UPC URLs redirect

**File:** `internal/handlers/gerichte.go` (45 occurrences of
`unifiedpatentcourt.org`, no hyphens)
**Severity:** Important (one redirect per link click; flagged as wrong by
link-checkers)
**Effort:** S

Canonical UPC website is `https://www.unified-patent-court.org` (with
hyphens). The no-hyphen form returns HTTP 403 challenge pages from
Cloudflare on naive `curl` but does resolve to the same destination.
`internal/handlers/links.go` uses the canonical form — the two files
are inconsistent. Pick one. Canonical (hyphenated) is safer.

**Fix:** sed-replace `unifiedpatentcourt.org` → `unified-patent-court.org`
across `gerichte.go` (and re-verify the deep paths still resolve —
some `/en/court/court-appeal` style URLs may have moved).

### I-4. `loadRecentActivity` omits personal Termine audit rows (not a bug yet, but will be)

**File:** `internal/services/dashboard_service.go:259-287`
**Severity:** Medium-Important (correctness risk)
**Effort:** S

`loadRecentActivity` joins `akten_events` → `akten`, so only
Akte-attached events appear. That is correct today because personal
Termine explicitly skip the audit (per Phase F memory). If a future
contributor adds a personal-Termin event type without reading the
design, this query will silently drop it — add a comment or switch to
`LEFT JOIN` + `WHERE a.id IS NULL OR <visibility>`.

### I-5. `/api/akten/{id}/events` has no pagination or size limit

**File:** `internal/services/akte_service.go:368-383`
**Severity:** Important (scalability; long-running Akten)
**Effort:** M

`ListEvents` returns every row ever written to `akten_events` for an
Akte. A three-year litigation with CalDAV sync and daily notizen edits
could easily reach 5–10 k rows. The Verlauf tab will then ship 2 MB of
JSON on each tab-click.

**Fix:** add `?before=<uuid>&limit=50` cursor pagination and render
"Load more" in the Verlauf tab. Already noted in the Phase E followup
list, never actioned.

### I-6. Glossar is missing FRAND / SEP / related commercial-patent terms

**File:** `internal/handlers/glossar.go:33-118` (~86 entries)
**Severity:** Important (content gap for the target audience)
**Effort:** S

HLC's patent practice is a heavy SEP/FRAND shop; the glossary has zero
entries for: FRAND, SEP, Standard-essentielles Patent, Patentpool, Anti-
Anti-Suit Injunction, Injunction gap, Orange-Book-Verfahren,
Huawei/ZTE-Verhandlungsmuster, RAND, ETSI IPR Policy, Patent-Hold-up,
Patent-Hold-out. These are table-stakes for the intended user.

**Fix:** add ~12 entries under a new `SEP/FRAND` category (or stretch
`Litigation`). Pull definitions from the canonical CJEU/BGH case law.

### I-7. README is out of date

**File:** `README.md:30-43,107`
**Severity:** Important (onboarding drag)
**Effort:** S

- Migration list stops at 013; 014 (`checklist_instances`) is live.
- Line 107 says "Phase I (Notizen) pending — service and UI aren't
  built yet"; it shipped on `mai/knuth/phase-i-notizen` (commit
  `5a9f8e5`, 2026-04-17) with full service + handlers + UI.

**Fix:** refresh the migrations block (`014_checklist_instances …`) and
the status paragraph. Phase I ✅ Done, Phase J docs-only done, infra
retirement pending.

### I-8. Legacy "patholo_" names everywhere

**File:** `internal/auth/auth.go:17-18`, `internal/handlers/links.go:315`,
`frontend/src/client/i18n.ts:6` (`STORAGE_KEY = "patholo-lang"`),
`frontend/src/client/*.ts` (other localStorage keys),
`paliad_link_suggestions` / `paliad_link_feedback` table names (compare
vs. the older `patholo_*` references in the code).
**Severity:** Important (brand inconsistency; minor confusion)
**Effort:** M

Cookie name `patholo_session`, storage key `patholo-lang`, Supabase
tables `patholo_link_suggestions` / `patholo_link_feedback`. Memory
notes a deliberate decision to *keep* the cookie name so users aren't
logged out — that's fine — but storage keys and table names can migrate
without user impact.

**Fix:** plan a single branch that:

- Renames `STORAGE_KEY` → `paliad-lang` with a one-shot migration from
  the old key (read old, write new, delete old) in `i18n.ts` init.
- Renames tables to `paliad_link_suggestions` / `paliad_link_feedback`
  (migration + update callers in `handlers/links.go`).
- Leaves the cookie name alone or migrates it with a dual-read grace
  period.

---

## 3 — Polish (nice to have)

### P-1. HL Intern links are stubs

`internal/handlers/links.go:206-219` — two entries with `URL: "#"`
render as clickable cards that go nowhere.

**Fix:** either remove the entries until real URLs land, or add a
"Coming soon — suggest a URL" flag + disabled styling.

### P-2. Dashboard says "Meine Mandate" but the nav and URL say "Akten"

`frontend/src/dashboard.tsx:82`, i18n key `dashboard.matters.heading`.
The project's naming convention (CLAUDE.md) mandates **Akten**
throughout — the term "Mandate" is explicitly historical. The dashboard
heading should read "Meine Akten" / "My Matters".

**Fix:** change i18n text to "Meine Akten" (DE) and leave
"My Matters" (EN). Rename i18n key if desired.

### P-3. Login page placeholder still says `name@hoganlovells.com`

Covered by **C-4**. Mentioned again here because the hint
"Nur für @hoganlovells.com Adressen." is a polish concern even after
the whitelist change — update to "Nur für autorisierte HLC-Adressen."

### P-4. Landing page footer reads "Hogan Lovells Patent Practice"

`frontend/src/components/Footer.tsx:7`. Brand reads as *old* on every
page. Switch to "HLC Patent Practice" post-merger (or drop the firm
name entirely since Paliad is supposed to survive renames).

### P-5. No explicit empty-state copy on Fristen-Kalender / Termine-Kalender

Check `frontend/src/fristen-kalender.tsx`, `termine-kalender.tsx`.
Calendars with no events render an empty grid — add a subtle "Keine
Fristen / Termine im ausgewählten Zeitraum." string.

### P-6. Office names in `AkteService.isValidOffice` diverge from UI labels

`akte_service.go:403-408` accepts keys `munich, duesseldorf, hamburg,
amsterdam, london, paris, milan`. The `models.go` user has the same
list. But the UI (landing page) writes "München", "Düsseldorf",
"Mailand". Currently fine because admins edit office via internal role
only, but any future user-facing office selector must map label →
key — add a single source of truth (`internal/calc/offices.go` or
similar: `{key: "munich", labelDE: "München", labelEN: "Munich"}`).

### P-7. `Glossar` CSVs hard-coded in one file (230 lines)

Not a bug, but as the list grows toward 150+ terms it wants to move out
of Go source into `internal/handlers/glossar_data.go` or a JSON blob in
`internal/data/glossar.json`. Easier for non-devs to edit.

### P-8. Dockerfile lacks `HEALTHCHECK` and runs as root

`Dockerfile:13-19`. No `HEALTHCHECK`, no `USER nonroot`. Both are easy
wins for Dokploy's health surface and container hardening.

```dockerfile
# before the CMD
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/ || exit 1
RUN addgroup -S paliad && adduser -S -G paliad paliad
USER paliad
```

Add a `GET /healthz` route that returns 200 without auth; current `/`
redirects and wgets a 200 anyway, but an explicit probe is cleaner.

### P-9. Landing-page copy still frames Paliad as "Paliad — Patentwissen für Hogan Lovells"

`frontend/src/client/i18n.ts:38-48`. With Phase 0 shipped, Paliad is
more than a knowledge hub — it's the Aktenverwaltung. The hero section
should call that out. Proposed: "Paliad — alles für den Patentalltag:
Akten, Fristen, Termine und Wissen."

### P-10. Kostenrechner PDF and Checklisten print — not verified by audit

The design says PDF export works. I did not test it; please verify
after any CSS change that affects `.tool-page` / `.print\:hide`.
Consider adding a visual-regression snapshot to the build.

---

## 4 — Features (prioritised backlog)

### F-1. Global search (Ctrl-K / Cmd-K)

**Impact:** Very High
**Effort:** M

A single keystroke that searches across Akten, Fristen, Termine,
Parteien, Glossar, Gerichte, Links. Patent lawyers hop between matters
constantly; the current UX requires clicking through 2 sidebar entries
to find anything. Index on the backend with `tsvector` + `pg_trgm`, or
build a client-side Lunr index for the static content and a single
`/api/search?q=…` that fans out for user data. Match by Aktenzeichen,
party name, Frist title.

### F-2. Verfahrensleitfäden (procedure guides)

**Impact:** Very High (per the original roadmap, never shipped)
**Effort:** L

Step-by-step UPC / BPatG / EPO workflows that stitch together
checklists, deadline rules, and recommended templates. This is the
biggest original-roadmap item still missing.

### F-3. Expand the Downloads registry

**Impact:** High (2/10 on the roadmap, partial)
**Effort:** M

Only `HL Patents Style.dotm` is wired up. mWorkRepo has more
templates. Wire `Vollmacht.dotm`, `Unterlassungserklärung.dotm`,
`UPC-Klageschrift-Skeleton.dotm` — mirror the existing proxy pattern.

### F-4. Benachrichtigungen (notifications)

**Impact:** Medium-High
**Effort:** L

Email digest of today's overdue/due Fristen + tomorrow's Termine, sent
at 07:00 CET per user. Supabase pg_cron + Edge Functions or a Go
scheduler in the Paliad binary. Opt-in per user.

### F-5. What's New / Changelog in-app

**Impact:** Medium
**Effort:** S

`docs/changelog.md` rendered under `/whatsnew` with a red dot on the
sidebar when there's an entry newer than the user's `last_seen_changelog`
timestamp. Cheap way to communicate feature releases.

### F-6. Akten-Kurzinfo on hover (pop-over)

**Impact:** Medium
**Effort:** S

Hovering an Aktenzeichen in any list (Dashboard activity, Fristen,
Termine) shows a tooltip with matter title, court, next Frist. Reduces
tab-switching.

### F-7. Parteien mit Kontaktkarten (Update endpoint)

**Impact:** Medium
**Effort:** S

Currently `ParteienService` only supports Create + Delete. No Update.
Typos in a party name force a delete + re-add and lose the ID. Add
PATCH `/api/parteien/{id}`.

### F-8. Fristenrechner — "Als Frist speichern" mit Wiedervorlage

**Impact:** Medium
**Effort:** S

When creating a Frist with a Rule, also derive a Wiedervorlage-Frist
(warning-date = due - 14 d) and offer to create both in one click.

### F-9. Dark mode

**Impact:** Low (but free with CSS variables)
**Effort:** S

`global.css` already uses CSS variables heavily. Add a
`@media (prefers-color-scheme: dark)` block + a manual toggle in the
sidebar.

### F-10. Document upload without AI (revisit Phase H)

**Impact:** Medium (still open per memory episode)
**Effort:** M

The Supabase Storage upload path already works; only the AI-extraction
part was rejected. A plain "upload PDF to Akte" with no auto-extraction
is still useful and unblocks the Dokumente tab.

### F-11. Akten-Tags / Labels

**Impact:** Medium
**Effort:** M

Free-form tags on Akten (e.g., `SEP`, `OLG-Düsseldorf`, `Q4-Priority`)
with a filter UI. Cheaper than a full custom-fields system and used
extensively at comparable firms.

---

## 5 — Technical Debt

### T-1. RLS policies exist but are never enforced

`internal/db/migrations/007_rls_policies.up.sql` defines full office-
scoped RLS keyed off `auth.uid()`. The Go backend uses a service-role
connection (`db.OpenPool` with `DATABASE_URL`), so those policies never
evaluate — every query bypasses RLS by design.

This is correct today (service-role means app-level checks must be
bulletproof, which they mostly are) but it means the RLS layer is dead
weight that increases surface for a migration bug to produce a
silently-wrong policy. Decide:

- **Option A:** accept the "belt-and-braces" position, keep RLS,
  document loudly that it's not active in prod.
- **Option B:** drop the RLS migration and policies — less code, less
  false sense of security.
- **Option C:** switch to PostgREST-style per-request JWT connections so
  RLS actually runs. This is the most defensive but a substantial
  rewrite.

My recommendation: Option A, with an `internal/db/RLS_NOTE.md` and a
`SET row_security = off;` (or equivalent) in the service-role
connection so the policies don't quietly cost perf.

### T-2. Go module path still `mgit.msbls.de/m/patholo`

`go.mod:1` + every `import` statement. Not breaking, but a daily
reminder of the old name. Rename on the next big refactor, not now —
every file in the repo would churn.

### T-3. `calDAVClient` hand-rolls iCal + WebDAV

Documented in the Phase F memory as a deliberate choice. Fine for now,
but re-evaluate when any of these lands:
- Importing foreign UIDs (currently skipped)
- RRULE / VTIMEZONE / DTSTART-with-tzid
- Multi-calendar support per user

At that point switch to `github.com/emersion/go-ical` +
`github.com/emersion/go-webdav`.

### T-4. `_dev/mock_supabase_auth.sql` lives inside `migrations/`

`internal/db/migrations/_dev/`. Embed.FS includes all files under
`migrations/` — the `_dev` directory is probably ignored by
`iofs.New` (directories not matching the pattern `NNN_*.sql` are
filtered) but it's one hop from a build bug. Move to
`internal/db/devtools/` or similar, outside the embed root.

### T-5. Client-side duplicated helpers

Memory's Phase E followup notes: `urgency-color` + `date-format` JS
helpers are duplicated across `fristen.ts`, `fristen-detail.ts`,
`fristen-kalender.ts`, `akten-detail.ts`. Extract to
`frontend/src/client/fristen-shared.ts`. Not urgent, but next time
anyone touches the Fristen UI: do this first.

### T-6. `/api/fristen?status=all` returns everything client-side filters

Per Phase E memory: fine at current volumes, but a partner with 500
completed Fristen will see the full 500 on every calendar load. Add
server-side date-range filtering before that's a support ticket.

### T-7. No error monitoring

The server logs to stdout (which Dokploy captures), but there is no
Sentry / log-based alert wiring. Nobody knows when something breaks in
prod until a user complains. Options:

- Wire Supabase log drain (if Dokploy supports it).
- Self-host a lightweight OpenTelemetry collector on mlake.
- At minimum: `GET /metrics` (Prometheus text) with basic counters, and
  a cron that posts to Gotify when error-rate crosses a threshold.

Pick the cheapest now — we can evolve later.

### T-8. No structured logging

Mixed `log.Printf` + `slog.Info` across the codebase. Pick one, default
to `slog` with a JSON handler in prod. Pre-populate user-id and
request-id in the context for correlation.

### T-9. No backup verification for `paliad` schema

Supabase's automatic backups cover the whole instance, but
`paliad.paliad_schema_migrations` collision history (memory episode
"paliad migration bootstrap collision") suggests the shared-Postgres
posture is fragile. Add a weekly `pg_dump --schema=paliad` cron on
mlake that writes to an offsite bucket and a monthly restore-smoke-test
(into an ephemeral Postgres container).

### T-10. Test coverage gaps

Only `akte_service_test.go`, `deadline_calculator_test.go`,
`holidays_test.go`, `fees_test.go` exist. Missing: `FristService`,
`TerminService`, `NotizService`, `CalDAVService` (unit tests for
iCal encode / decode, encrypt / decrypt, visibility edge-cases).
The CalDAV crypto in particular should have a table-driven test with
known vectors.

### T-11. `internal/models/models.go` is one big file

Not inherently bad, but it's the dumping ground for 14 types. Split by
domain (`user.go`, `akte.go`, `frist.go`, `termin.go`, …) next time it's
touched.

### T-12. Dockerfile build cache hygiene

Every `COPY . .` invalidates on any source change, forcing a full `go
mod download`. Move `go.mod`/`go.sum` copy + `go mod download` *before*
`COPY . .` — already done. But also consider `FROM golang:1.24-alpine
AS backend` → `golang:1.24` with `CGO_ENABLED=0` so libc-less `alpine`
runtime doesn't fight any future pgx upgrade. Minor; today it's fine.

### T-13. `frontend/build.ts` has 24 hand-maintained render-and-write pairs

Any new page requires edits in 4 places (build.ts, handlers.go
registration, i18n keys, CSS). Consider a page-manifest — an array
`[{slug, render, clientEntry}]` — that the build script iterates over.
Less ceremony; harder to forget one of the four places.

### T-14. `Gitea-backed file proxy` has no ETag / If-Modified-Since

`internal/handlers/files.go` caches bytes and the commit SHA in memory
but never sets an `ETag` or `Last-Modified` response header, so every
browser re-downloads a 500 KB .dotm on each click. Add:
`w.Header().Set("ETag", entry.sha)` + handle `If-None-Match` with a
304. Cheap win.

---

## Delivery suggestion

- **Week 1 (critical):** C-1, C-2, C-3, C-4, C-5. All small- to
  medium-effort and gates production readiness.
- **Week 2 (important):** I-1, I-2, I-3, I-7, I-8. Polish the
  post-merger brand story.
- **Week 3 (features, pick 2):** F-1 (global search) gives the biggest
  daily-use win; F-2 (Verfahrensleitfäden) is the biggest content win;
  F-3 (Downloads) closes a roadmap item cheaply.
- **Always-on (tech debt):** pick one T-item per sprint until they're
  gone.

None of the critical items are theoretical — the JWT signature bypass
and the dashboard Termine leak are real exploit-paths I could walk
through today with a curl command and a Paliad cookie. Fix those two
first.
