diff --git a/docs/design-data-model-v2.md b/docs/design-data-model-v2.md new file mode 100644 index 0000000..79fa017 --- /dev/null +++ b/docs/design-data-model-v2.md @@ -0,0 +1,920 @@ +# Data Model v2 — Clients, nestable Projects, Teams + +**Author:** cronus (inventor) +**Date:** 2026-04-20 +**Task:** t-paliad-023 +**Status:** Design draft for review. Supersedes the flat `paliad.akten` model in `design-kanzlai-integration.md` §2–§3. +**Scope:** Schema + migration plan. No implementation in this change. + +--- + +## Executive summary + +**Recommendation.** Replace the flat `paliad.akten` table with a two-table core: + +1. `paliad.mandanten` — clients (companies or people who instruct HLC). +2. `paliad.projekte` — a **single, self-referential, typed tree** of all work. Every row has a `project_type` (`mandat`, `litigation`, `patent`, `verfahren`, `projekt`), an optional `parent_project_id`, and a required `client_id` that points to the Mandant at the root of the tree. Fristen, Termine, Notizen, Dokumente and Parteien all hang off a **single polymorphic `project_id`** — "polymorphic" only in the sense that any node in the tree can own them, not in the sense of multi-table FKs. + +**Teams** become explicit rows. `paliad.teams` holds both Dezernate (structural, one partner-led unit per row) and — as a separate concept — Project Teams (ad-hoc, per-project roster). The `paliad.users.dezernat` free-text field is superseded by `paliad.team_mitglieder`. The `paliad.akten.collaborators uuid[]` array on every Akte is replaced by `paliad.projekt_mitglieder`, giving us per-user roles inside a project and a sane target for audit + invitations. + +**Visibility** stays office-scoped, but the predicate now walks the tree: seeing *any* node in a project grants the viewer access to the whole connected tree (root, siblings, descendants). This matches how lawyers actually use the data — a Munich associate put on one UPC Case of a Siemens litigation must see the parent litigation and the sibling Cases to do their job, and a lead partner put on the litigation root must see everything below. + +**Naming.** Stay German, matching everything shipped so far — `mandanten`, `projekte`, `teams`, `team_mitglieder`, `projekt_mitglieder`. German plural for table names, singular for Go structs (`Mandant`, `Projekt`, `Team`). `User` stays English (Supabase concept). + +**Migration** is phased and non-destructive. Existing `paliad.akten` rows survive as `projekte` rows with `project_type='verfahren'` (best match for the flat-Akte pattern), the same primary key UUIDs, and `client_id` nullable during a cleanup window (the partner UI collects real Mandant assignment). Every child table (`fristen`, `termine`, `notizen`, `dokumente`, `parteien`, `akten_events`, `checklist_instances`) gets its FK column renamed from `akte_id` to `project_id` in a follow-on migration — no data move, just a DDL rename. The existing `/akten` URLs stay live as aliases of `/projekte`. + +**This is the foundation for Paliad v2.** It is opinionated. The trade-offs are called out inline. + +--- + +## 1. Entity-Relationship + +### 1.1 Mermaid + +```mermaid +erDiagram + USERS ||--o{ TEAM_MITGLIEDER : "belongs to" + USERS ||--o{ PROJEKT_MITGLIEDER : "staffed on" + TEAMS ||--o{ TEAM_MITGLIEDER : "has members" + + MANDANTEN ||--o{ PROJEKTE : "owns" + PROJEKTE ||--o{ PROJEKTE : "parent of" + PROJEKTE ||--o{ PROJEKT_MITGLIEDER : "has team" + + PROJEKTE ||--o{ FRISTEN : "1:N" + PROJEKTE ||--o{ TERMINE : "1:N (nullable)" + PROJEKTE ||--o{ NOTIZEN : "1:N (polymorphic parent)" + PROJEKTE ||--o{ DOKUMENTE : "1:N" + PROJEKTE ||--o{ PARTEIEN : "1:N" + PROJEKTE ||--o{ AKTEN_EVENTS : "1:N (audit)" + PROJEKTE ||--o{ CHECKLIST_INSTANCES : "1:N (nullable)" + + FRISTEN ||--o{ NOTIZEN : "parent (optional)" + TERMINE ||--o{ NOTIZEN : "parent (optional)" + AKTEN_EVENTS ||--o{ NOTIZEN : "parent (optional)" + + TEAMS { + uuid id PK + text type "dezernat | project_team" + text name + uuid partner_id FK "for dezernat; NULL for project team" + uuid projekt_id FK "for project team; NULL for dezernat" + text office "seat of the dezernat; NULL for project team" + bool is_active + } + + MANDANTEN { + uuid id PK + text name + text legal_form "e.g., AG, GmbH, Einzelerfinder" + text industry + text country + text billing_reference + jsonb key_contacts + text owning_office "Default office for new Projekte" + uuid[] collaborators "Firm-wide people with explicit Mandant access" + bool firm_wide_visible + text status "active | archived" + } + + PROJEKTE { + uuid id PK + uuid client_id FK "nullable during migration" + uuid parent_project_id FK "self" + text project_type "mandat|litigation|patent|verfahren|projekt" + text title + text reference "human-readable ref (Aktenzeichen)" + text external_ref "EP no., UPC docket, etc." + text court + text court_ref + text status "active | pending | closed | archived" + text owning_office + bool firm_wide_visible + uuid created_by FK + jsonb metadata + ltree path "materialised ancestor path" + int depth "0 = root" + } + + PROJEKT_MITGLIEDER { + uuid projekt_id PK FK + uuid user_id PK FK + text role "lead|associate|pa|of_counsel|local_counsel|expert|observer" + timestamptz added_at + uuid added_by FK + } + + TEAM_MITGLIEDER { + uuid team_id PK FK + uuid user_id PK FK + text role "partner|associate|pa|trainee|of_counsel|secretariat" + timestamptz added_at + } +``` + +### 1.2 ASCII worked example + +Showing the Siemens portfolio from the brief. `[P]` = partner, `[A]` = associate, `[LC]` = local counsel. Node IDs are illustrative. + +``` +MANDANTEN: M1 "Siemens AG" (industry=industrial, owning_office=munich) +│ +└── PROJEKTE (client_id=M1) + │ + └── P0 project_type=mandat "Siemens — Overall relationship" (path=P0, depth=0) + │ owning_office=munich + │ Team: [P Lead=partner_a@munich] [A associate_b@munich] + │ + └── P1 project_type=litigation "Siemens v. Huawei — SEP Portfolio" (path=P0.P1, depth=1) + │ owning_office=munich, firm_wide_visible=false + │ Team: [P Lead=partner_a@munich] [A associate_c@duesseldorf] [LC local_uk@london] + │ + ├── P2 project_type=patent "EP 1 234 567" (path=P0.P1.P2, depth=2) + │ │ external_ref=EP1234567 + │ │ + │ ├── P3 project_type=verfahren "UPC Infringement UPC_CFI_123/2026"(path=P0.P1.P2.P3, depth=3) + │ │ court=UPC_CFI_Munich, court_ref=UPC_CFI_123/2026 + │ │ └─ Fristen, Termine, Notizen, Dokumente all project_id=P3 + │ │ + │ ├── P4 project_type=verfahren "EPO Opposition W 0001/26" + │ └── P5 project_type=verfahren "BPatG Nullity 3 Ni 45/26" + │ + ├── P6 project_type=patent "EP 2 345 678" + │ └── P7 project_type=verfahren "UPC Infringement UPC_CFI_456/2026" + │ + └── P8 project_type=patent "EP 3 456 789" + └── P9 project_type=verfahren "LG München I 21 O 12345/26" +``` + +The key insight: **every box is a row in `paliad.projekte`**. Fristen/Termine/Notizen live at whichever node is the right home. Dashboard aggregates across all nodes the user can see by walking the visibility predicate. + +--- + +## 2. Table schemas + +### 2.1 `paliad.mandanten` + +```sql +CREATE TABLE paliad.mandanten ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + legal_form text, -- 'AG', 'GmbH', 'Inc.', 'Einzelerfinder', … + industry text, -- free text, not an enum + country text, -- ISO 3166-1 alpha-2 ('DE','US',…) + billing_reference text, -- matches the firm-wide billing system + key_contacts jsonb NOT NULL DEFAULT '[]'::jsonb, + -- [{"name":"…","role":"…","email":"…","phone":"…"}] + owning_office text NOT NULL CHECK (owning_office IN ( + 'munich','duesseldorf','hamburg', + 'amsterdam','london','paris','milan')), + -- Visibility knobs, analogous to paliad.akten today: + collaborators uuid[] NOT NULL DEFAULT '{}', + firm_wide_visible boolean NOT NULL DEFAULT false, + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active','archived')), + metadata jsonb NOT NULL DEFAULT '{}', + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX mandanten_owning_office_idx ON paliad.mandanten (owning_office); +CREATE INDEX mandanten_firm_wide_idx ON paliad.mandanten (firm_wide_visible) WHERE firm_wide_visible = true; +CREATE INDEX mandanten_collaborators_gin ON paliad.mandanten USING GIN (collaborators); +CREATE INDEX mandanten_name_trgm ON paliad.mandanten USING GIN (name gin_trgm_ops); +``` + +Notes: +- `key_contacts` is JSONB not a child table. Contacts don't have their own identity (they're denormalised name/email/phone); pulling them into `paliad.contacts` would be overkill and block nothing. If HLC later wants CRM-style contact records, promote to a table in a follow-on. +- `owning_office` on `mandanten` is the **default** for new Projekte; individual Projekte can override. +- Duplicated visibility knobs (`collaborators`, `firm_wide_visible`) are intentional: a user can have Mandant-level visibility without being on any particular Projekt (e.g., the relationship partner who hasn't been staffed on a specific case yet). The predicate OR-fans these in. + +### 2.2 `paliad.projekte` + +```sql +-- Enable the ltree extension in Supabase (first migration to use it). +CREATE EXTENSION IF NOT EXISTS ltree; + +-- project_type is a text + CHECK, not an enum type. Enums are painful to extend +-- in Postgres migrations; text + CHECK gives us the same validation with room +-- to add a new type by replacing the constraint. +CREATE TABLE paliad.projekte ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid REFERENCES paliad.mandanten(id) ON DELETE RESTRICT, + -- NULLABLE during migration; + -- enforced NOT NULL in a later phase. + parent_project_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE, + + project_type text NOT NULL CHECK (project_type IN + ('mandat','litigation','patent','verfahren','projekt')), + title text NOT NULL, + reference text, -- firm-internal human ref (Aktenzeichen) + external_ref text, -- EP no., UPC docket, BPatG ref, … + court text, + court_ref text, + + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active','pending','closed','archived')), + + owning_office text NOT NULL CHECK (owning_office IN ( + 'munich','duesseldorf','hamburg', + 'amsterdam','london','paris','milan')), + firm_wide_visible boolean NOT NULL DEFAULT false, + + -- Materialised tree state. Maintained by a BEFORE INSERT/UPDATE trigger. + -- `path` is the ltree of ancestor ids ending with this row's id. + path ltree NOT NULL, + depth int NOT NULL CHECK (depth >= 0), + + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + metadata jsonb NOT NULL DEFAULT '{}', + ai_summary text, -- unused today; kept from akten + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + -- Sanity: a project's client must match its parent's client (and root of + -- tree carries the single source of truth). + CONSTRAINT projekte_parent_self_differs CHECK (parent_project_id IS NULL OR parent_project_id <> id) +); + +-- Trees are navigated by path. GiST over ltree makes ancestor / descendant +-- lookups <@ / @> work in O(log n) — critical for the visibility predicate. +CREATE INDEX projekte_path_gist ON paliad.projekte USING GIST (path); +CREATE INDEX projekte_parent_idx ON paliad.projekte (parent_project_id); +CREATE INDEX projekte_client_idx ON paliad.projekte (client_id); +CREATE INDEX projekte_client_type_idx ON paliad.projekte (client_id, project_type) + WHERE status <> 'archived'; +CREATE INDEX projekte_owning_office_idx ON paliad.projekte (owning_office); +CREATE INDEX projekte_firm_wide_idx ON paliad.projekte (firm_wide_visible) WHERE firm_wide_visible = true; +CREATE INDEX projekte_status_idx ON paliad.projekte (status); +CREATE INDEX projekte_reference_trgm ON paliad.projekte USING GIN (reference gin_trgm_ops); +CREATE INDEX projekte_title_trgm ON paliad.projekte USING GIN (title gin_trgm_ops); +``` + +**Why ltree and not a recursive CTE?** RLS is called once per candidate row on every SELECT. A recursive CTE per row is O(depth) repeated per predicate call. `path @> ancestor_path` uses the GiST index and collapses to one index scan. This is the biggest performance decision in the doc; ltree is the right tool. + +**Why a materialised path *and* `parent_project_id`?** The parent FK is the source of truth for the tree (used by `ON DELETE CASCADE`). The `path` + `depth` columns are derived state maintained by a trigger. Keeping both is redundant on purpose — the FK guarantees referential integrity; the path gives us fast traversal. Updates to `parent_project_id` re-compute path for the subtree in the trigger. + +**Tree trigger sketch:** + +```sql +CREATE FUNCTION paliad.projekte_sync_path() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + parent_path ltree; +BEGIN + IF NEW.parent_project_id IS NULL THEN + NEW.path := text2ltree(replace(NEW.id::text, '-', '_')); + NEW.depth := 0; + ELSE + SELECT path, depth + 1 INTO parent_path, NEW.depth + FROM paliad.projekte + WHERE id = NEW.parent_project_id; + IF parent_path IS NULL THEN + RAISE EXCEPTION 'parent project % not found', NEW.parent_project_id; + END IF; + NEW.path := parent_path || text2ltree(replace(NEW.id::text, '-', '_')); + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER projekte_sync_path_ins + BEFORE INSERT ON paliad.projekte + FOR EACH ROW EXECUTE FUNCTION paliad.projekte_sync_path(); + +-- On parent change, re-path both this row and every descendant. +CREATE FUNCTION paliad.projekte_rewrite_subtree() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.parent_project_id IS DISTINCT FROM OLD.parent_project_id THEN + PERFORM paliad.projekte_sync_path() FROM paliad.projekte WHERE id = NEW.id; + UPDATE paliad.projekte + SET path = NEW.path || subpath(path, nlevel(OLD.path) - 1), + depth = NEW.depth + (nlevel(path) - nlevel(OLD.path)) + WHERE path <@ OLD.path + AND id <> NEW.id; + END IF; + RETURN NEW; +END; +$$; +``` + +(Sketch — implementer will refine. The constraint that uuid-hyphens aren't valid in ltree labels means we encode UUIDs with `-`→`_`. Alternative: use `hashtext(id::text)::text` to keep labels short — discuss with implementer.) + +### 2.3 `paliad.teams` + +**Two kinds, one table.** The shape is similar enough that splitting into `dezernate` + `project_teams` would mostly duplicate columns. A `type` column + partial CHECK constraints is cheaper. + +```sql +CREATE TABLE paliad.teams ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + type text NOT NULL CHECK (type IN ('dezernat','project_team')), + name text NOT NULL, + + -- For type = 'dezernat': + partner_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + office text CHECK (office IS NULL OR office IN ( + 'munich','duesseldorf','hamburg', + 'amsterdam','london','paris','milan')), + + -- For type = 'project_team': + projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE, + + is_active boolean NOT NULL DEFAULT true, + metadata jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + -- Shape invariants per type. + CONSTRAINT teams_dezernat_shape CHECK ( + type <> 'dezernat' + OR (partner_id IS NOT NULL AND office IS NOT NULL AND projekt_id IS NULL) + ), + CONSTRAINT teams_project_team_shape CHECK ( + type <> 'project_team' + OR (projekt_id IS NOT NULL AND partner_id IS NULL AND office IS NULL) + ) +); + +-- One project_team per Projekt (if any). +CREATE UNIQUE INDEX teams_one_team_per_projekt + ON paliad.teams (projekt_id) WHERE type = 'project_team'; + +CREATE INDEX teams_partner_idx ON paliad.teams (partner_id) WHERE type = 'dezernat'; +CREATE INDEX teams_office_idx ON paliad.teams (office) WHERE type = 'dezernat'; +CREATE INDEX teams_projekt_idx ON paliad.teams (projekt_id) WHERE type = 'project_team'; +``` + +**Critique already anticipated.** Mixing two entities in one table is a smell. I accept the smell because: (a) the queries we actually run split cleanly by `type`; (b) the join from a user to "every team I'm on" wants a single table; (c) project teams and dezernate both feed the same `team_mitglieder` roster table and the same per-team role enum overlap is ~80%. If we discover real divergence (project teams grow a `stage` field, dezernate grow a `parent_dezernat_id`), split then. + +### 2.4 `paliad.team_mitglieder` + +Roster for **both** kinds of team. Dezernat and project-team memberships coexist — a user is typically in exactly one Dezernat *and* on multiple project teams. + +```sql +CREATE TABLE paliad.team_mitglieder ( + team_id uuid NOT NULL REFERENCES paliad.teams(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role text NOT NULL CHECK (role IN ( + 'partner','associate','pa','trainee','of_counsel', + 'secretariat','lead','local_counsel','expert','observer' + )), + added_at timestamptz NOT NULL DEFAULT now(), + added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + PRIMARY KEY (team_id, user_id) +); + +CREATE INDEX team_mitglieder_user_idx ON paliad.team_mitglieder (user_id); +``` + +Role values overlap intentionally: `partner`, `associate`, `pa`, `trainee`, `of_counsel`, `secretariat` are the typical Dezernat roles; `lead`, `associate`, `pa`, `of_counsel`, `local_counsel`, `expert`, `observer` are the typical project-team roles. The union is finite and small — don't over-engineer with separate role enums per team type. + +### 2.5 `paliad.projekt_mitglieder` (replaces `collaborators uuid[]`) + +I recommend **flattening project-team rosters into a dedicated junction table** instead of going through `teams` + `team_mitglieder` for the project-team case. Reason: project-team membership is the hot path for RLS. A dedicated two-column junction with a covering index beats any indirection through `teams`. + +```sql +CREATE TABLE paliad.projekt_mitglieder ( + projekt_id uuid NOT NULL REFERENCES paliad.projekte(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role text NOT NULL CHECK (role IN ( + 'lead','associate','pa','of_counsel','local_counsel','expert','observer' + )), + added_at timestamptz NOT NULL DEFAULT now(), + added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + PRIMARY KEY (projekt_id, user_id) +); + +CREATE INDEX projekt_mitglieder_user_idx ON paliad.projekt_mitglieder (user_id); +``` + +**So what's `paliad.teams` type='project_team' for?** Two things the junction can't express alone: (a) the *identity* of the team as a first-class object (naming, status, metadata, invitations); (b) a place to attach team-level settings (e.g., a project-team Slack channel URL, a CalDAV group calendar id). If we decide we don't need either, `teams` shrinks to dezernate only and `project_team` rows go away. That's fine too — implementation can start with junction-only and add `teams` rows on demand. + +**Opinion:** start with `projekt_mitglieder` only. Add `teams` rows for project teams if/when we ship team-scoped features (project Slack channel, group calendar). For the purposes of visibility and role, the junction table is sufficient. + +### 2.6 Child tables — single `project_id` polymorphic FK + +No polymorphic magic. Every node in the tree is a `projekte` row. A Frist attached to "the Siemens SEP litigation" just FKs to the litigation-level projekt. A Frist on the root Mandat-level projekt is rare but expressible. Polymorphic multi-FK tables (with multiple nullable parent columns + a CHECK) are a pattern we have today on `notizen`, and they create RLS pain — we avoid extending it. + +Revised child tables (columns that change only): + +| Table | Today | v2 | +|---|---|---| +| `paliad.parteien` | `akte_id NOT NULL` | `project_id NOT NULL` | +| `paliad.fristen` | `akte_id NOT NULL` | `project_id NOT NULL` | +| `paliad.termine` | `akte_id NULL` | `project_id NULL` (personal stays NULL) | +| `paliad.dokumente` | `akte_id NOT NULL` | `project_id NOT NULL` | +| `paliad.akten_events` | `akte_id NOT NULL` | `project_id NOT NULL` (table stays `akten_events` for history; see §10) | +| `paliad.checklist_instances` | `akte_id NULL` | `project_id NULL` (personal stays NULL) | +| `paliad.notizen` | 4 nullable FKs (akte/frist/termin/event), 1-of CHECK | **Keep as-is** — still polymorphic across frist/termin/event/*project*; `akte_id`→`project_id`. | + +`notizen` stays polymorphic because notes attach to three different kinds of entity (Projekt, Frist, Termin, AktenEvent) — a note on a Frist is *not* the same thing as a note on the owning Projekt. The alternative (always attach at Projekt and store `frist_id` / `termin_id` in metadata) loses referential integrity; reject it. + +### 2.7 `paliad.users` changes + +- **Drop:** `dezernat text` (free-text, only introduced in migration 015). +- **Keep:** `office`, `role`, `practice_group`, `lang`, `email_preferences`. +- **New:** nothing — Dezernat membership moves to `team_mitglieder` with `team_id` pointing at a `teams` row of type `dezernat`. + +Migration 015 left `dezernat` as free text precisely because the partner might not have registered yet. v2 keeps the freedom differently: during onboarding, the user either (a) picks an existing Dezernat from a dropdown (fed from `paliad.teams WHERE type='dezernat'`) or (b) types a new one, and the onboarding service auto-creates a `teams` row with `partner_id = NULL` and a note "claim this partnership by signing up with role=partner". The partner claims on their own first-login. + +--- + +## 3. Polymorphic FK strategy (the explicit recommendation) + +> **Single `project_id` on all child tables (except `notizen`).** + +Rationale: +- Everything a Frist/Termin/Dokument/Partei could "attach to" is now a row in `paliad.projekte`. A Mandant-level Frist FKs to the `project_type='mandat'` root. A verfahren-level Frist FKs to the `project_type='verfahren'` leaf. No discriminator column, no CHECK constraint juggling. +- RLS becomes one predicate: `paliad.can_see_project(project_id)`. Today's `can_see_akte()` + `notiz_is_visible()` split goes away for 6 of 7 child tables. +- Client visibility (a Mandant-level Frist like "send yearly renewal reminder") is uniform: it just lives on the Mandant-level projekt — no `client_id` FK on child tables, no third polymorphic branch. +- Query aggregation across a client's work ("show me all deadlines in the next 30 days for Siemens") is a single JOIN: `fristen JOIN projekte ON fristen.project_id = projekte.id WHERE projekte.path <@ (SELECT path FROM projekte WHERE id = )`. + +Alternatives I considered and rejected: +- **Multiple nullable FKs (`client_id`, `litigation_id`, `patent_id`, `verfahren_id`) with a 1-of CHECK.** Reproduces the notizen pain for every child table. Harder to index, harder for RLS. Rejected. +- **`parent_type text` + `parent_id uuid` (classic polymorphic)**. Kills foreign-key integrity. Rejected. +- **Separate child tables per level (`mandat_fristen`, `litigation_fristen`, …)**. Absurd proliferation. Rejected on sight. + +`notizen` is the one exception because a note genuinely attaches to one of four *kinds of entity*, not to four different positions in the same tree. Keep the 4-FK-one-nullable shape (akte_id → **project_id**, frist_id, termin_id, akten_event_id; CHECK = 1-of-4). + +--- + +## 4. Visibility model + +### 4.1 Design principles + +1. Visibility is **tree-connected**: if you can see one node, you can see the whole tree (root → all descendants). Mimics how litigation teams actually work. +2. Office-scoping stays **at the project level**, not the Mandant level, because different Projekte under one client may legitimately belong to different offices (e.g., the client's Munich patent prosecution vs. their Düsseldorf enforcement). +3. Project-team membership **grants visibility**, including for users outside `owning_office`. +4. Mandant-level visibility (`mandanten.collaborators`, `mandanten.firm_wide_visible`) grants visibility to the **Mandant** and its **entire project tree**. This is the firm-wide or relationship-partner override. +5. `admin` role sees everything. + +### 4.2 The predicate, in English + +A user U can see a Projekt P iff **any** of the following: + +- `P.firm_wide_visible = true`, **or** +- `P.owning_office = U.office`, **or** +- U is in `projekt_mitglieder` for P, **or** +- U is in `projekt_mitglieder` for **any ancestor or descendant of P** (tree-connected visibility), **or** +- `P.client_id` points to a Mandant M where: + - `M.firm_wide_visible = true`, **or** + - U's uuid ∈ `M.collaborators`, **or** +- `U.role = 'admin'`. + +A user U can see a Mandant M iff **any** of: +- `M.firm_wide_visible = true`, **or** +- `M.owning_office = U.office`, **or** +- U's uuid ∈ `M.collaborators`, **or** +- U can see **any** Projekt under M (inductive), **or** +- `U.role = 'admin'`. + +### 4.3 SQL predicate + +```sql +-- Canonical visibility predicate for projects. Used in RLS and mirrored at +-- the service layer (AkteService.ListVisibleForUser equivalent). +CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = paliad, public +AS $$ + WITH me AS ( + SELECT id, office, role FROM paliad.users WHERE id = auth.uid() + ), + tgt AS ( + SELECT id, client_id, owning_office, firm_wide_visible, path + FROM paliad.projekte + WHERE id = _project_id + ) + SELECT EXISTS (SELECT 1 FROM tgt WHERE tgt.firm_wide_visible) + OR EXISTS (SELECT 1 FROM tgt, me WHERE tgt.owning_office = me.office) + OR EXISTS (SELECT 1 FROM me WHERE me.role = 'admin') + OR EXISTS ( + -- membership at target, or at any ancestor/descendant + SELECT 1 FROM paliad.projekt_mitglieder pm, tgt + WHERE pm.user_id = auth.uid() + AND pm.projekt_id IN ( + SELECT id FROM paliad.projekte + WHERE path @> tgt.path OR path <@ tgt.path + ) + ) + OR EXISTS ( + SELECT 1 FROM paliad.mandanten m, tgt + WHERE m.id = tgt.client_id + AND (m.firm_wide_visible + OR auth.uid() = ANY (m.collaborators)) + ); +$$; +``` + +The `path @> tgt.path OR path <@ tgt.path` clause is the tree-connected check: any project whose path is an ancestor or descendant of the target's path. Uses the GiST index on `projekte.path`. + +**Performance note.** For a litigation tree of ~20 nodes with ~100 members, the predicate runs a handful of index probes per row. Measurably worse than today's flat `can_see_akte()` (one EXISTS), but still sub-millisecond in Postgres. If we ever see RLS cost dominate a listing page, the follow-on is to cache "visible project ids for user X" in a session-scoped CTE at the application layer (same trick we use in `ListVisibleForUser`). + +### 4.4 Cross-office teams — concretely + +Example: Munich partner (Dezernat A) leads; Düsseldorf associate and London local counsel are staffed in. + +- The Litigation-level Projekt is created with `owning_office = 'munich'`. +- The Munich partner, the Düsseldorf associate, and the London local counsel all get rows in `projekt_mitglieder` (roles `lead`, `associate`, `local_counsel`). +- Result: the Düsseldorf associate can see the whole litigation tree even though `owning_office <> 'duesseldorf'`. Munich-office colleagues not on the team can still see it (office-scope). London colleagues not on the team cannot see it. + +**Edge case: "Chinese-walled" cases.** If a single Akte needs to be hidden from the rest of `owning_office` (conflict of interest), `owning_office` can't carry the day. Add a boolean `restricted` column in a later iteration that flips the predicate to team-only. Don't build now — wait for the first real conflict. + +--- + +## 5. Migration plan + +Non-destructive, phased. Data survives at every step. + +### Phase 1 — Add new tables (no FK rewrites) + +Migration `018_v2_core_tables`: +- `CREATE EXTENSION IF NOT EXISTS ltree;` +- Create `paliad.mandanten`, `paliad.projekte`, `paliad.teams`, `paliad.team_mitglieder`, `paliad.projekt_mitglieder`. +- Create the path trigger. +- No change to `paliad.akten` or its children yet. Old code keeps running. + +Acceptance: `\dt paliad.*` shows the new tables. Smoke test: insert a Mandant + one Projekt tree, query via `path @>`. + +### Phase 2 — Backfill `paliad.projekte` from `paliad.akten` (synthetic Mandanten) + +Migration `019_v2_backfill_projects_from_akten`: +- For each distinct `owning_office` with any Akte, create one synthetic Mandant: `name = 'Unbekannter Mandant ()'`, `status = 'active'`, `owning_office = `, `metadata = {"synthetic": true}`. +- Insert a `paliad.projekte` row for every `paliad.akten` row. **Same UUID** (`projekte.id = akten.id`), `client_id = synthetic mandant of matching office`, `parent_project_id = NULL`, `project_type = 'verfahren'` (best-match to current flat Akte semantics — most are single proceedings), `title`, `reference = aktenzeichen`, `owning_office`, `firm_wide_visible`, `created_by` copied 1:1. The path trigger populates `path` and `depth`. +- Also backfill `projekt_mitglieder` from `paliad.akten.collaborators` (array → rows with `role='associate'`). + +Acceptance: `(SELECT COUNT(*) FROM paliad.projekte) = (SELECT COUNT(*) FROM paliad.akten)`; every akten id survives as a projekte id. Visibility-predicate returns the same answers as `can_see_akte` for every (user, akte) pair (spot-check). + +### Phase 3 — Rename FK columns on child tables to `project_id` + +Migration `020_v2_rename_akte_id_to_project_id`: +- For `parteien`, `fristen`, `termine`, `dokumente`, `akten_events`, `checklist_instances`: `ALTER TABLE … RENAME COLUMN akte_id TO project_id;` and `ALTER TABLE … RENAME CONSTRAINT TO ;` plus rewrite the REFERENCES target from `paliad.akten` to `paliad.projekte`. +- For `notizen`: `RENAME COLUMN akte_id TO project_id;` similarly; keep `frist_id/termin_id/akten_event_id` intact. +- Because of the shared UUID trick in Phase 2, no data moves. Indexes are renamed in the same migration. + +Acceptance: `\d paliad.fristen` shows `project_id uuid NOT NULL REFERENCES paliad.projekte(id)`. Existing SELECTs joining to `paliad.akten` now break — that's the signal to cut the application code over in Phase 4. + +### Phase 4 — Cut application code over to `paliad.projekte` + +- Rename Go types: `models.Akte` → `models.Projekt`. Keep `models.Akte` as a deprecated type alias for one release for external API compatibility if needed. +- `services.AkteService` → `services.ProjektService`. Preserve method signatures; internals switch to `paliad.projekte`. +- Update handlers. `/api/akten` becomes an alias to `/api/projekte` (same handler, same JSON shape during transition — `projekte` additionally exposes `client_id`, `parent_project_id`, `project_type`, `path` fields). +- Update the dashboard query to aggregate by `projekte` (tree-walk already shown in §4.3). + +Acceptance: the app runs end-to-end on the new schema; old routes still resolve; old JSON shapes still accepted (new fields additive). + +### Phase 5 — New Mandant UI + partner cleanup + +- `/mandanten` list + detail pages. Partners assign every synthetic-Mandant project to a real Mandant row (bulk "Change Mandant" on the project-detail page, or a dedicated migration UI at `/einstellungen/migration`). +- After every `projekte.client_id` in `metadata->>'synthetic'=true` has been reassigned, drop the synthetic Mandanten and enforce `paliad.projekte.client_id SET NOT NULL` (migration 021). + +Acceptance: no synthetic Mandanten remain. `client_id` is NOT NULL. + +### Phase 6 — Decommission `paliad.akten` + +- `DROP TABLE paliad.akten` (migration 022). Everything that referenced it has been rewritten. +- Drop the legacy `paliad.can_see_akte()` function; the one-to-one function becomes `can_see_project()`. + +Acceptance: `\dt paliad.akten` → not found. All FK constraints still satisfy. + +### Rollback + +Every migration has a `down`: +- Phases 3 and 6 are destructive DDL (drop column rename → rename back; drop table → re-create). Data preservation in those down-migrations is **not guaranteed** after the migration completes; the safe rollback window is "before Phase 3 runs in production". Document loudly. +- Phases 1, 2, 4, 5 are additive or app-level, rollback by reverting code or running `DELETE FROM paliad.projekte WHERE …`. + +--- + +## 6. RLS policy updates + +### 6.1 New policies + +`paliad.projekte` — enable RLS. Policies: + +```sql +CREATE POLICY projekte_select ON paliad.projekte + FOR SELECT TO authenticated + USING (paliad.can_see_project(id)); + +-- Non-admins can only create Projekte rooted in an office they belong to +-- (or a tree whose existing parent they can already see). +CREATE POLICY projekte_insert ON paliad.projekte + FOR INSERT TO authenticated + WITH CHECK ( + -- Creating under an existing parent? — must already see it. + (parent_project_id IS NOT NULL AND paliad.can_see_project(parent_project_id)) + OR + -- Root project: own office, or admin. + (parent_project_id IS NULL + AND (owning_office = (SELECT office FROM paliad.users WHERE id = auth.uid()) + OR (SELECT role FROM paliad.users WHERE id = auth.uid()) = 'admin')) + ); + +CREATE POLICY projekte_update ON paliad.projekte + FOR UPDATE TO authenticated + USING (paliad.can_see_project(id)) + WITH CHECK (paliad.can_see_project(id)); + +-- Delete: partner/admin only. Cascades down the tree. +CREATE POLICY projekte_delete ON paliad.projekte + FOR DELETE TO authenticated + USING ( + paliad.can_see_project(id) + AND (SELECT role FROM paliad.users WHERE id = auth.uid()) IN ('partner','admin') + ); +``` + +`paliad.mandanten` — enable RLS. Visibility: any user who can see at least one of the Mandant's Projekte, or who is in `collaborators`, or `firm_wide_visible`, or admin. + +```sql +CREATE OR REPLACE FUNCTION paliad.can_see_mandant(_mandant_id uuid) +RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = paliad, public AS $$ + SELECT EXISTS ( + SELECT 1 FROM paliad.mandanten m, paliad.users u + WHERE m.id = _mandant_id + AND u.id = auth.uid() + AND ( + m.firm_wide_visible + OR m.owning_office = u.office + OR auth.uid() = ANY (m.collaborators) + OR u.role = 'admin' + OR EXISTS ( + SELECT 1 FROM paliad.projekte p + WHERE p.client_id = _mandant_id + AND paliad.can_see_project(p.id) + ) + ) + ); +$$; + +CREATE POLICY mandanten_select ON paliad.mandanten + FOR SELECT TO authenticated + USING (paliad.can_see_mandant(id)); + +CREATE POLICY mandanten_insert ON paliad.mandanten + FOR INSERT TO authenticated + WITH CHECK ( + owning_office = (SELECT office FROM paliad.users WHERE id = auth.uid()) + OR (SELECT role FROM paliad.users WHERE id = auth.uid()) = 'admin' + ); + +-- Update/Delete policies analogous; delete is partner/admin-gated. +``` + +### 6.2 Child-table policies — converge on `can_see_project` + +Every child table's policy changes from `paliad.can_see_akte(akte_id)` to `paliad.can_see_project(project_id)`. `notizen` loses its dedicated `notiz_is_visible()` helper in favour of an inline check that dispatches by which FK is set: + +```sql +CREATE POLICY notizen_all ON paliad.notizen + FOR ALL TO authenticated + USING ( + CASE + WHEN project_id IS NOT NULL THEN paliad.can_see_project(project_id) + WHEN frist_id IS NOT NULL THEN paliad.can_see_project( + (SELECT project_id FROM paliad.fristen WHERE id = frist_id)) + WHEN termin_id IS NOT NULL THEN + CASE + WHEN (SELECT project_id FROM paliad.termine WHERE id = termin_id) IS NULL + THEN (SELECT created_by FROM paliad.termine WHERE id = termin_id) = auth.uid() + ELSE paliad.can_see_project( + (SELECT project_id FROM paliad.termine WHERE id = termin_id)) + END + WHEN akten_event_id IS NOT NULL THEN paliad.can_see_project( + (SELECT project_id FROM paliad.akten_events WHERE id = akten_event_id)) + ELSE false + END + ) + WITH CHECK (...same...); +``` + +(We may extract a helper `notiz_is_visible(project_id, frist_id, termin_id, akten_event_id)` again — symmetric to today's.) + +### 6.3 Admin bootstrap + +Unchanged. The `pg_advisory_xact_lock(7346298141)` onboarding gate that lets the first user self-assign `role='admin'` still works. Nothing to do. + +### 6.4 Defense-in-depth at the service layer + +Same pattern as today: every service mirrors the predicate in `ListVisibleForUser` for indexed performance. The SQL is wordier for the tree variant — we do it once in `ProjektService.listVisibleIDsForUser` (returns a `map[uuid.UUID]struct{}`) and re-use across list endpoints. + +--- + +## 7. API surface changes + +### 7.1 New endpoints + +| Method + path | Purpose | +|---|---| +| `GET /api/mandanten` | List Mandanten the user can see. | +| `POST /api/mandanten` | Create Mandant. | +| `GET /api/mandanten/{id}` | Detail. | +| `PATCH /api/mandanten/{id}` | Update. | +| `DELETE /api/mandanten/{id}` | Delete (partner/admin only). | +| `GET /api/mandanten/{id}/projekte` | List root-level Projekte under this Mandant. | +| `GET /api/projekte` | List top-level visible Projekte (flat root list). | +| `POST /api/projekte` | Create a Projekt (optionally nested via `parent_project_id`). | +| `GET /api/projekte/{id}` | Detail + immediate children. | +| `GET /api/projekte/{id}/tree` | Full subtree (depth-first). | +| `PATCH /api/projekte/{id}` | Update. | +| `DELETE /api/projekte/{id}` | Cascade delete subtree (partner/admin). | +| `GET /api/projekte/{id}/kinder` | Direct children (for lazy-loaded UI). | +| `POST /api/projekte/{id}/team` | Add project team member. | +| `DELETE /api/projekte/{id}/team/{user_id}` | Remove member. | +| `GET /api/teams` | List Dezernate (+ optionally project teams). | +| `POST /api/teams` | Create Dezernat (admin-gated). | +| `GET /api/teams/{id}/mitglieder` | List members. | + +### 7.2 Aliased endpoints + +During transition: +- `GET /api/akten` → alias of `GET /api/projekte?project_type=verfahren` (or all, with `akte_type`/`court` fields surfaced) for clients still using the old shape. +- `POST /api/akten` → alias of `POST /api/projekte` with `project_type='verfahren'` default. +- `/api/akten/{id}/fristen` → alias of `/api/projekte/{id}/fristen`. + +Remove aliases after 60 days + 1 green deployment. + +### 7.3 Retired endpoints + +None immediately. Keeping aliases means no 404s for the UI during the Phase 4 cutover. + +### 7.4 New query parameters + +- `?client_id=` on `GET /api/projekte` — scope to one Mandant's tree. +- `?project_type=` — filter by type. +- `?ancestor=` — subtree of a given root (uses `path <@`). +- `?include_children=true` on `GET /api/projekte/{id}` — one-shot detail + subtree (cheap because of the path index). + +--- + +## 8. UI implications + +### 8.1 Sidebar + +Insert "Mandanten" above "Akten" in the "ARBEIT" group. Rename "Akten" → "Projekte" in a second phase once partners get used to the concept — initially, keep "Akten" as the label and add the Mandanten entry only. + +``` +— ARBEIT — + Dashboard + Mandanten ← NEW + Projekte ← renamed from "Akten" (phase 2) + Fristen + Termine +``` + +### 8.2 New pages + +- `/mandanten` — list. Columns: Name, Büro, #Projekte, #aktive Fristen, letzte Aktivität. +- `/mandanten/neu` — create form. +- `/mandanten/{id}` — detail with tabs: Übersicht, Projekte (the tree at this root), Fristen (aggregated), Termine (aggregated), Notizen (aggregated), Team (aggregated from all child projekt_mitglieder). +- `/projekte` — flat list of root projects + filter by Mandant, type, office. +- `/projekte/{id}` — detail. Tabs today (`verlauf`, `parteien`, `fristen`, `termine`, `dokumente`, `notizen`, `checklisten`) stay. **New first tab: "Untergeordnet"** — renders the subtree of child projekte as a collapsible list. A "Neues Untervorhaben" button under each node creates a child. +- `/projekte/neu` — create. Form adapts to `project_type`: `mandat`/`litigation`/`patent` surface no `court` / `court_ref`; `verfahren` does. + +### 8.3 Detail-page tree rendering + +On `/projekte/{id}` the sidebar (left sub-nav) renders the ancestor path (breadcrumbs: Mandant → Litigation → Patent → Verfahren). Children render in the Untergeordnet tab as a collapsible tree. Keep click-depth low: clicking a child navigates to that child's detail page; siblings render as flat siblings on the current page. + +### 8.4 Team editor + +On `/projekte/{id}` under the Team tab: list of team members with role, "Mitglied hinzufügen" modal with autocomplete fed by `GET /api/users`. Removing the `collaborators uuid[]` array means the multi-pick UI gets a proper role column and an "added_by/at" audit line, which today's model can't show. + +### 8.5 Dashboard impact + +Dashboard queries aggregate across **every** Projekt the user can see (all tree levels). Fristen summary widget: `SELECT * FROM paliad.fristen f JOIN paliad.projekte p ON f.project_id = p.id WHERE can_see_project(p.id) AND f.status='pending' AND f.due_date <= now() + interval '30 days'`. Same tree-agnostic pattern for Termine. + +"Neu auf ..." — add a Mandanten-level aggregate: "Siemens AG: 3 neue Fristen diese Woche, 1 Verhandlung am Donnerstag". Requires a `client_id` join via `projekte` — same index pattern. + +### 8.6 Fristenrechner "Save to Akte" + +Rename button to "Zur Verfahren-Akte speichern" (or simpler: "Zur Akte speichern" still works because Verfahren are the most common target). The target picker is now a two-step autocomplete: Mandant → Projekt (scoped to that Mandant's tree). Or skip Mandant picker entirely and autocomplete across all visible projekte — simpler, lets the user jump straight to the right leaf by typing the court-ref. + +### 8.7 Checklisten Akten-link + +Minimal change: `akte_id` → `project_id` on the service layer and UI picker. Picker is the same cross-tree autocomplete. + +### 8.8 CalDAV sync + +No material change. Today each Termin's iCal includes the Akte's `aktenzeichen` in the DESCRIPTION. v2 includes the full path: `Siemens AG · Siemens v. Huawei · EP 1 234 567 · UPC_CFI_123/2026`. Lawyers will find events in their calendar far more easily. + +--- + +## 9. Impact on existing features + +| Feature | Change | +|---|---| +| Dashboard | Queries shift from `akten` to `projekte`; widgets aggregate tree-wide. Mandanten-level "Neu auf ..." widget added. | +| Fristenrechner | Save-to-Projekt instead of save-to-Akte. Autocomplete is cross-tree (all visible projekte). | +| Fristen list | Columns show `Mandant · Projekt` chain instead of flat Aktenzeichen. Filter by Mandant. | +| Termine list | Same. | +| Notizen | FK rename only (akte_id → project_id). Polymorphic shape preserved. | +| Checklisten | FK rename only. Picker widened to cross-tree autocomplete. | +| CalDAV | Richer DESCRIPTION (full path). Termin ↔ calendar event mapping unchanged. | +| Akten detail page | Gains Untergeordnet tab + tree sub-nav. Existing tabs keep working. | +| Audit trail (Verlauf) | `akten_events` stays as a table name (history); `akte_id` → `project_id`. New event types: `projekt_nested`, `projekt_reparented`, `mandant_assigned`, `team_member_added`, `team_member_removed`. | +| Dokumente (placeholder) | No change today (still placeholder). Future implementation attaches to the right level of the tree. | + +--- + +## 10. Naming conventions — German, with one English holdover + +**Decision: German throughout. Match everything shipped so far.** + +| Concept | DB table | Go struct | Go service | URL | German UI | +|---|---|---|---|---|---| +| Client | `paliad.mandanten` | `Mandant` | `MandantService` | `/mandanten` | "Mandant" / "Mandanten" | +| Project (generic) | `paliad.projekte` | `Projekt` | `ProjektService` | `/projekte` | "Projekt" / "Projekte" | +| Project sub-type "Mandat" | (row in projekte) | — | — | (row variant) | "Mandat" (Gesamtbeziehung) | +| Project sub-type "Litigation" | (row in projekte) | — | — | (row variant) | "Streitsache" | +| Project sub-type "Patent" | (row in projekte) | — | — | (row variant) | "Patent" | +| Project sub-type "Verfahren" | (row in projekte) | — | — | (row variant) | "Verfahren" | +| Project sub-type "Projekt" | (row in projekte) | — | — | (row variant) | "Projekt" (generisch) | +| Team (structural / project) | `paliad.teams` | `Team` | `TeamService` | `/teams` (admin) | "Team" / "Teams" | +| Team member | `paliad.team_mitglieder` | `TeamMitglied` | — | (sub) | "Teammitglied" | +| Project roster | `paliad.projekt_mitglieder` | `ProjektMitglied` | — | (sub of Projekt) | "Projektmitglied" | +| Party | `paliad.parteien` | `Partei` | `ParteienService` | (sub of Projekt) | "Partei" / "Parteien" | +| Deadline | `paliad.fristen` | `Frist` | `FristService` | `/fristen` | "Frist" / "Fristen" | +| Appointment | `paliad.termine` | `Termin` | `TerminService` | `/termine` | "Termin" / "Termine" | +| Document | `paliad.dokumente` | `Dokument` | `DokumentService` | (sub) | "Dokument" | +| Audit event | `paliad.akten_events` | `AkteEvent` | (in `ProjektService`) | n/a | "Verlauf" | +| Note | `paliad.notizen` | `Notiz` | `NotizService` | cross-cutting | "Notiz" / "Notizen" | +| User | `paliad.users` | `User` | `UserService` | n/a | n/a | + +**The one English holdover:** `paliad.akten_events` table name. Rationale: +- It's the audit-trail table name already shipped. +- "Verlauf" is the UI label; the table name is invisible to users. +- Migrating to `projekt_events` would churn migration history for no gain, and any historical tools (Grafana, ad-hoc SQL) keep working. Preserve the name; update the comment and the Go struct semantics. + +**Why not `projekt_*` everywhere?** +- `projekt_mitglieder` reads cleanly; kept. +- `projekt_events` would be clean too but see above — churn:benefit ratio unfavourable. + +**URL aliases.** `/akten` and `/akten/{id}` keep redirecting to `/projekte` and `/projekte/{id}` indefinitely (bookmark preservation). No hard break. 301 from `/akten/neu` → `/projekte/neu`. + +--- + +## 11. Open questions & deferrable decisions + +1. **Patent registry.** Should `external_ref` on a `patent`-type projekt be a FK to a firm-wide `paliad.patente` table (EP number + metadata, shared across Mandanten)? Not today — a single litigation's view of a patent is legitimately separate from the firm-wide "patents we've ever seen" list. Revisit when HLC asks for firm-wide IP inventory. If/when built, add `patent_registry_id uuid` on `projekte` where `project_type='patent'`. + +2. **Conflict of interest / Chinese walls.** Partner-level override to restrict a single project to its team roster only (strip office-scope). Not built now; add `restricted boolean` in a follow-on migration, and extend the predicate to skip the office-scope branch when `restricted = true`. + +3. **Practice-group scoping.** `paliad.users.practice_group` is already free-text. If HLC splits Patents Litigation vs. Patents Prosecution and wants wall-like isolation, add `visible_to_groups text[]` on `projekte` + predicate extension. Not now. + +4. **Matrix management.** A user belongs to one Dezernat (structural) today. If partners share associates (e.g., a "tax-patent" associate is on both the Patents and the Tax Dezernate), relax `team_mitglieder` — it already allows multiple memberships. The onboarding UI currently picks one Dezernat; relax when needed. + +5. **Billing hooks.** `mandanten.billing_reference` is provisioned but not wired. Deliberate: HLC has firm-wide billing; Paliad does not compete. Field exists so the UI can show it and the future Outlook/Exchange integration can look it up. + +6. **External collaborators.** A Milan boutique working on a case today would go into `projekt_mitglieder` only if they have Supabase accounts. Building external-party access (email-only, scoped, audit-logged) is a post-foundation feature; deferred. + +7. **Hard delete vs. archive.** `status='archived'` on Mandanten and Projekte exists; hard-delete is cascade via FK. Consider a `archived_at` + soft-delete semantics once we have retention-policy rules. Not now. + +8. **ltree label encoding.** UUIDs with hyphens aren't valid ltree labels. Replace `-` with `_`, or hash. Implementer's call; both work, hash is shorter but loses traceability. + +--- + +## 12. Trade-off summary (for the head) + +| Choice | Alternative | Why I picked this | Cost | +|---|---|---|---| +| Single `projekte` tree with type enum | Separate tables per type (mandate/litigation/patent/case) | Polymorphic FK pain, cross-tree queries, UI shared components | `project_type` CHECK has to grow carefully | +| ltree materialised path | Recursive CTE | RLS is the hottest call site; O(log n) tree queries matter | Extension dependency; label encoding quirk | +| Single `project_id` on child tables | Multi-level polymorphic FKs | RLS simplicity, uniform service code | Discipline: every Fristen/Termin has a Projekt, even "client-level" rare cases | +| `mandanten` as a separate table | Project with `type='mandat'` as the conceptual client | Clients have no deadlines/termine/parteien; they're a different shape. Also: Mandant outlives any specific matter. | One extra table | +| `teams` shared between Dezernat + project_team | Two separate tables | Single roster table (`team_mitglieder`), UI/service reuse | Partial CHECK constraints are a minor smell | +| `projekt_mitglieder` junction in addition to `teams` | Route project-team membership through `teams` | Hot path for RLS wants a dedicated two-column junction | Small duplication of concept | +| German naming | Mixed EN/DE | Continuity with everything shipped; audience speaks German | German plural forms (`mandanten`, `projekte`) in URLs | +| Tree-connected visibility | Downward-only (seeing ancestor grants ancestor+descendants only) | Matches how associate-on-one-case actually needs parent context | Slightly bigger RLS query | +| Phased non-destructive migration with preserved UUIDs | Dump-transform-reload | Zero downtime; every child-table row survives untouched | Requires discipline: match UUIDs in the backfill exactly | + +--- + +## 13. Who implements? + +Recommendation: **I (cronus) can implement the foundation** — migrations 018–022, the predicate function, the new `ProjektService`, and the API alias shim. Reasons: +- I wrote the design; I know the edge cases. +- The schema work is security-critical (RLS policy + path trigger); having design-context on the implementer cuts review cycles. +- The pragmatic split: cronus does schema + services + aliases + RLS (Phase 1–4). A parallel coder worker does the Mandanten UI (`/mandanten` list + detail + create + partner cleanup wizard) in Phase 5. Cronus does Phase 6 decommission. + +If the head prefers to keep cronus on design duty and hand implementation to a coder, the design is detailed enough to hand off — every schema has columns, constraints, triggers, RLS snippets, and migration acceptance criteria. I'd still want to review the RLS + path trigger PR before merge. + +--- + +## 14. Acceptance criteria for the design itself + +A "yes" on this design means head agrees to: + +- [ ] Mandanten as a first-class table (not just a Projekt type). +- [ ] Single `projekte` tree with 5-value type enum. +- [ ] ltree materialised path + GiST index. +- [ ] Single `project_id` FK on fristen/termine/dokumente/parteien/akten_events/checklist_instances; `notizen` keeps its polymorphic shape with `akte_id` renamed to `project_id`. +- [ ] Tree-connected visibility predicate (ancestors + descendants both reachable from any team node). +- [ ] `paliad.teams` as a single table for Dezernat + project-team, with the two-kind shape CHECK. +- [ ] `projekt_mitglieder` as a hot-path junction, *and* optional `teams` rows of type `project_team` for team-level features. +- [ ] Phased migration with preserved UUIDs between `akten` and `projekte` rows. +- [ ] German naming throughout; `akten_events` table name preserved for continuity. +- [ ] `/akten` URLs alias to `/projekte` indefinitely.