design: data model v2 — Mandanten + nestable Projekte + Teams (t-paliad-023)

Comprehensive design doc for the replacement of flat paliad.akten with:
  - paliad.mandanten (Clients as first-class table)
  - paliad.projekte (single self-referential typed tree, ltree materialised
    path, 5 project types: mandat/litigation/patent/verfahren/projekt)
  - paliad.teams + paliad.team_mitglieder (Dezernate + project teams in one
    table with kind-shape CHECK)
  - paliad.projekt_mitglieder (hot-path junction replacing akten.collaborators)

Polymorphic FK strategy: single project_id FK on fristen/termine/dokumente/
parteien/akten_events/checklist_instances. Notizen keeps its 4-way polymorphic
shape (akte_id renamed to project_id).

Visibility model: tree-connected — seeing any node grants access to the whole
tree (ancestors + descendants). Office-scope stays at project level; Mandant-
level firm_wide_visible / collaborators override.

Migration plan: 6 phases, non-destructive. UUIDs preserved between akten and
projekte rows so child tables only need column renames, no data moves.

Opinionated: German naming throughout (mandanten, projekte, teams,
team_mitglieder, projekt_mitglieder); /akten URLs alias to /projekte
indefinitely; akten_events table name kept for continuity.

Deliverable: docs/design-data-model-v2.md (920 lines, 14 sections).
This commit is contained in:
m
2026-04-20 14:17:32 +02:00
parent b370d59eee
commit fabe32aa56

View File

@@ -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 = <mandat-projekt-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 (<office>)'`, `status = 'active'`, `owning_office = <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 <akte_fk> TO <project_fk>;` 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=<uuid>` on `GET /api/projekte` — scope to one Mandant's tree.
- `?project_type=<type>` — filter by type.
- `?ancestor=<uuid>` — 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 018022, 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 14). 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.