# 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 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.
