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).
54 KiB
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:
paliad.mandanten— clients (companies or people who instruct HLC).paliad.projekte— a single, self-referential, typed tree of all work. Every row has aproject_type(mandat,litigation,patent,verfahren,projekt), an optionalparent_project_id, and a requiredclient_idthat points to the Mandant at the root of the tree. Fristen, Termine, Notizen, Dokumente and Parteien all hang off a single polymorphicproject_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_contactsis JSONB not a child table. Contacts don't have their own identity (they're denormalised name/email/phone); pulling them intopaliad.contactswould be overkill and block nothing. If HLC later wants CRM-style contact records, promote to a table in a follow-on.owning_officeonmandantenis 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_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_mitgliederwithteam_idpointing at ateamsrow of typedezernat.
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_idon all child tables (exceptnotizen).
Rationale:
- Everything a Frist/Termin/Dokument/Partei could "attach to" is now a row in
paliad.projekte. A Mandant-level Frist FKs to theproject_type='mandat'root. A verfahren-level Frist FKs to theproject_type='verfahren'leaf. No discriminator column, no CHECK constraint juggling. - RLS becomes one predicate:
paliad.can_see_project(project_id). Today'scan_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_idFK 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
- 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.
- 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).
- Project-team membership grants visibility, including for users outside
owning_office. - 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. adminrole 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, orP.owning_office = U.office, or- U is in
projekt_mitgliederfor P, or - U is in
projekt_mitgliederfor any ancestor or descendant of P (tree-connected visibility), or P.client_idpoints 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, orM.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(roleslead,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.aktenor 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_officewith any Akte, create one synthetic Mandant:name = 'Unbekannter Mandant (<office>)',status = 'active',owning_office = <office>,metadata = {"synthetic": true}. - Insert a
paliad.projekterow for everypaliad.aktenrow. 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_bycopied 1:1. The path trigger populatespathanddepth. - Also backfill
projekt_mitgliederfrompaliad.akten.collaborators(array → rows withrole='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;andALTER TABLE … RENAME CONSTRAINT <akte_fk> TO <project_fk>;plus rewrite the REFERENCES target frompaliad.aktentopaliad.projekte. - For
notizen:RENAME COLUMN akte_id TO project_id;similarly; keepfrist_id/termin_id/akten_event_idintact. - 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. Keepmodels.Akteas a deprecated type alias for one release for external API compatibility if needed. services.AkteService→services.ProjektService. Preserve method signatures; internals switch topaliad.projekte.- Update handlers.
/api/aktenbecomes an alias to/api/projekte(same handler, same JSON shape during transition —projekteadditionally exposesclient_id,parent_project_id,project_type,pathfields). - 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
/mandantenlist + 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_idinmetadata->>'synthetic'=truehas been reassigned, drop the synthetic Mandanten and enforcepaliad.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 becomescan_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 ofGET /api/projekte?project_type=verfahren(or all, withakte_type/courtfields surfaced) for clients still using the old shape.POST /api/akten→ alias ofPOST /api/projektewithproject_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>onGET /api/projekte— scope to one Mandant's tree.?project_type=<type>— filter by type.?ancestor=<uuid>— subtree of a given root (usespath <@).?include_children=trueonGET /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 toproject_type:mandat/litigation/patentsurface nocourt/court_ref;verfahrendoes.
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_eventswould 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_mitgliederreads cleanly; kept.projekt_eventswould 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
-
Patent registry. Should
external_refon apatent-type projekt be a FK to a firm-widepaliad.patentetable (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, addpatent_registry_id uuidonprojektewhereproject_type='patent'. -
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 booleanin a follow-on migration, and extend the predicate to skip the office-scope branch whenrestricted = true. -
Practice-group scoping.
paliad.users.practice_groupis already free-text. If HLC splits Patents Litigation vs. Patents Prosecution and wants wall-like isolation, addvisible_to_groups text[]onprojekte+ predicate extension. Not now. -
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. -
Billing hooks.
mandanten.billing_referenceis 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. -
External collaborators. A Milan boutique working on a case today would go into
projekt_mitgliederonly if they have Supabase accounts. Building external-party access (email-only, scoped, audit-logged) is a post-foundation feature; deferred. -
Hard delete vs. archive.
status='archived'on Mandanten and Projekte exists; hard-delete is cascade via FK. Consider aarchived_at+ soft-delete semantics once we have retention-policy rules. Not now. -
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 (
/mandantenlist + 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
projektetree with 5-value type enum. - ltree materialised path + GiST index.
- Single
project_idFK on fristen/termine/dokumente/parteien/akten_events/checklist_instances;notizenkeeps its polymorphic shape withakte_idrenamed toproject_id. - Tree-connected visibility predicate (ancestors + descendants both reachable from any team node).
paliad.teamsas a single table for Dezernat + project-team, with the two-kind shape CHECK.projekt_mitgliederas a hot-path junction, and optionalteamsrows of typeproject_teamfor team-level features.- Phased migration with preserved UUIDs between
aktenandprojekterows. - German naming throughout;
akten_eventstable name preserved for continuity. /aktenURLs alias to/projekteindefinitely.