Files
paliad/docs/design-data-model-v2.md
m fabe32aa56 design: data model v2 — Mandanten + nestable Projekte + Teams (t-paliad-023)
Comprehensive design doc for the replacement of flat paliad.akten with:
  - paliad.mandanten (Clients as first-class table)
  - paliad.projekte (single self-referential typed tree, ltree materialised
    path, 5 project types: mandat/litigation/patent/verfahren/projekt)
  - paliad.teams + paliad.team_mitglieder (Dezernate + project teams in one
    table with kind-shape CHECK)
  - paliad.projekt_mitglieder (hot-path junction replacing akten.collaborators)

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

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

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

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

Deliverable: docs/design-data-model-v2.md (920 lines, 14 sections).
2026-04-20 14:17:32 +02:00

54 KiB
Raw Permalink Blame History

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

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

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

-- 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:

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.

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.

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.

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_idproject_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

-- 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.Aktemodels.Projekt. Keep models.Akte as a deprecated type alias for one release for external API compatibility if needed.
  • services.AkteServiceservices.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:

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.

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:

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.

Minimal change: akte_idproject_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_idproject_id. New event types: projekt_nested, projekt_reparented, mandant_assigned, team_member_added, team_member_removed.
Dokumente (placeholder) No change today (still placeholder). Future implementation attaches to the right level of the tree.

10. Naming conventions — German, with one English holdover

Decision: German throughout. Match everything shipped so far.

Concept DB table Go struct Go service URL German UI
Client paliad.mandanten Mandant MandantService /mandanten "Mandant" / "Mandanten"
Project (generic) paliad.projekte Projekt ProjektService /projekte "Projekt" / "Projekte"
Project sub-type "Mandat" (row in projekte) (row variant) "Mandat" (Gesamtbeziehung)
Project sub-type "Litigation" (row in projekte) (row variant) "Streitsache"
Project sub-type "Patent" (row in projekte) (row variant) "Patent"
Project sub-type "Verfahren" (row in projekte) (row variant) "Verfahren"
Project sub-type "Projekt" (row in projekte) (row variant) "Projekt" (generisch)
Team (structural / project) paliad.teams Team TeamService /teams (admin) "Team" / "Teams"
Team member paliad.team_mitglieder TeamMitglied (sub) "Teammitglied"
Project roster paliad.projekt_mitglieder ProjektMitglied (sub of Projekt) "Projektmitglied"
Party paliad.parteien Partei ParteienService (sub of Projekt) "Partei" / "Parteien"
Deadline paliad.fristen Frist FristService /fristen "Frist" / "Fristen"
Appointment paliad.termine Termin TerminService /termine "Termin" / "Termine"
Document paliad.dokumente Dokument DokumentService (sub) "Dokument"
Audit event paliad.akten_events AkteEvent (in ProjektService) n/a "Verlauf"
Note paliad.notizen Notiz NotizService cross-cutting "Notiz" / "Notizen"
User paliad.users User UserService n/a n/a

The one English holdover: paliad.akten_events table name. Rationale:

  • It's the audit-trail table name already shipped.
  • "Verlauf" is the UI label; the table name is invisible to users.
  • Migrating to projekt_events would churn migration history for no gain, and any historical tools (Grafana, ad-hoc SQL) keep working. Preserve the name; update the comment and the Go struct semantics.

Why not projekt_* everywhere?

  • projekt_mitglieder reads cleanly; kept.
  • projekt_events would be clean too but see above — churn:benefit ratio unfavourable.

URL aliases. /akten and /akten/{id} keep redirecting to /projekte and /projekte/{id} indefinitely (bookmark preservation). No hard break. 301 from /akten/neu/projekte/neu.


11. Open questions & deferrable decisions

  1. Patent registry. Should external_ref on a patent-type projekt be a FK to a firm-wide paliad.patente table (EP number + metadata, shared across Mandanten)? Not today — a single litigation's view of a patent is legitimately separate from the firm-wide "patents we've ever seen" list. Revisit when HLC asks for firm-wide IP inventory. If/when built, add patent_registry_id uuid on projekte where project_type='patent'.

  2. Conflict of interest / Chinese walls. Partner-level override to restrict a single project to its team roster only (strip office-scope). Not built now; add restricted boolean in a follow-on migration, and extend the predicate to skip the office-scope branch when restricted = true.

  3. Practice-group scoping. paliad.users.practice_group is already free-text. If HLC splits Patents Litigation vs. Patents Prosecution and wants wall-like isolation, add visible_to_groups text[] on projekte + predicate extension. Not now.

  4. Matrix management. A user belongs to one Dezernat (structural) today. If partners share associates (e.g., a "tax-patent" associate is on both the Patents and the Tax Dezernate), relax team_mitglieder — it already allows multiple memberships. The onboarding UI currently picks one Dezernat; relax when needed.

  5. Billing hooks. mandanten.billing_reference is provisioned but not wired. Deliberate: HLC has firm-wide billing; Paliad does not compete. Field exists so the UI can show it and the future Outlook/Exchange integration can look it up.

  6. External collaborators. A Milan boutique working on a case today would go into projekt_mitglieder only if they have Supabase accounts. Building external-party access (email-only, scoped, audit-logged) is a post-foundation feature; deferred.

  7. Hard delete vs. archive. status='archived' on Mandanten and Projekte exists; hard-delete is cascade via FK. Consider a archived_at + soft-delete semantics once we have retention-policy rules. Not now.

  8. ltree label encoding. UUIDs with hyphens aren't valid ltree labels. Replace - with _, or hash. Implementer's call; both work, hash is shorter but loses traceability.


12. Trade-off summary (for the head)

Choice Alternative Why I picked this Cost
Single projekte tree with type enum Separate tables per type (mandate/litigation/patent/case) Polymorphic FK pain, cross-tree queries, UI shared components project_type CHECK has to grow carefully
ltree materialised path Recursive CTE RLS is the hottest call site; O(log n) tree queries matter Extension dependency; label encoding quirk
Single project_id on child tables Multi-level polymorphic FKs RLS simplicity, uniform service code Discipline: every Fristen/Termin has a Projekt, even "client-level" rare cases
mandanten as a separate table Project with type='mandat' as the conceptual client Clients have no deadlines/termine/parteien; they're a different shape. Also: Mandant outlives any specific matter. One extra table
teams shared between Dezernat + project_team Two separate tables Single roster table (team_mitglieder), UI/service reuse Partial CHECK constraints are a minor smell
projekt_mitglieder junction in addition to teams Route project-team membership through teams Hot path for RLS wants a dedicated two-column junction Small duplication of concept
German naming Mixed EN/DE Continuity with everything shipped; audience speaks German German plural forms (mandanten, projekte) in URLs
Tree-connected visibility Downward-only (seeing ancestor grants ancestor+descendants only) Matches how associate-on-one-case actually needs parent context Slightly bigger RLS query
Phased non-destructive migration with preserved UUIDs Dump-transform-reload Zero downtime; every child-table row survives untouched Requires discipline: match UUIDs in the backfill exactly

13. Who implements?

Recommendation: I (cronus) can implement the foundation — migrations 018022, the predicate function, the new ProjektService, and the API alias shim. Reasons:

  • I wrote the design; I know the edge cases.
  • The schema work is security-critical (RLS policy + path trigger); having design-context on the implementer cuts review cycles.
  • The pragmatic split: cronus does schema + services + aliases + RLS (Phase 14). A parallel coder worker does the Mandanten UI (/mandanten list + detail + create + partner cleanup wizard) in Phase 5. Cronus does Phase 6 decommission.

If the head prefers to keep cronus on design duty and hand implementation to a coder, the design is detailed enough to hand off — every schema has columns, constraints, triggers, RLS snippets, and migration acceptance criteria. I'd still want to review the RLS + path trigger PR before merge.


14. Acceptance criteria for the design itself

A "yes" on this design means head agrees to:

  • Mandanten as a first-class table (not just a Projekt type).
  • Single projekte tree with 5-value type enum.
  • ltree materialised path + GiST index.
  • Single project_id FK on fristen/termine/dokumente/parteien/akten_events/checklist_instances; notizen keeps its polymorphic shape with akte_id renamed to project_id.
  • Tree-connected visibility predicate (ancestors + descendants both reachable from any team node).
  • paliad.teams as a single table for Dezernat + project-team, with the two-kind shape CHECK.
  • projekt_mitglieder as a hot-path junction, and optional teams rows of type project_team for team-level features.
  • Phased migration with preserved UUIDs between akten and projekte rows.
  • German naming throughout; akten_events table name preserved for continuity.
  • /akten URLs alias to /projekte indefinitely.