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