# Design — Project hierarchy aggregation, effective team, partner-unit derivation

**Author:** noether (inventor)
**Date:** 2026-05-06
**Task:** t-paliad-139 (Gitea m/paliad#4)
**Branch:** `mai/noether/inventor-project`
**Status:** LOCKED 2026-05-06 — m signed off on all 19 §6 recommendations. Ready for coder shift on head's assignment.

---

## 0. TL;DR

Three coordinated sub-designs in one doc, scoped tight per the locked constraints (m, 2026-05-06):

1. **Surface-by-surface aggregation policy** — a per-view decision table for whether a project filter narrows to the row's own `project_id` or rolls up the whole subtree under it. Fixes the bug m hit on `/projects/{client_id}` (descendant deadlines invisible) and pins the contract everywhere else.
2. **Effective-team semantics** — direct team ∪ all descendants' teams ∪ partner-unit-derived members. Adds two pieces of schema the current data model is missing (a project↔unit junction and a unit-role distinction) so derivation has somewhere to live, and keeps derivation **honest** (annotated everywhere, never silent).
3. **Approval-policy interaction with hierarchy and derivation** — explicit coordination with t-paliad-138 (cronus's just-shipped 4-eye design). Resolves "if a deadline lives on a Case but the policy is on the Client, does the policy inherit down?" and "do derived members carry approval authority?".

### Locked m decisions (2026-05-06, per the issue body)

- Behaviour is **surface-specific**.
- Must cover events / deadlines / termine.
- **"Effective Team of a Client" = direct ∪ all descendants' teams ∪ partner-unit-derived PAs.**
- **PA derivation rule:** a partner-unit on a project auto-derives its PAs onto the project team.
- **Derivation honesty:** derived members are annotated in project team overviews, never silently granted access.
- **paliad-only scope.**

### Locked m decisions on §6 (2026-05-06, design pass)

m signed off on **all 19 recommendations** in §6 verbatim. Implementation may proceed on head's assignment without further design Q&A. Highlights of the locks (full table is §6):

- **Aggregate-by-default** with "nur direkt" toggle on /events, /deadlines, /appointments, and the four /projects/{id} sections (Q1, Q4, Q5, Q16).
- **Sidebar dual counts** `(direct + descendants)` (Q2).
- **Dashboard / CalDAV / email reminders unchanged** — visibility-scope already correct (Q3, Q7, Q8).
- **Search subtree-default in project context, global otherwise** (Q6).
- **Unit-level derivation** (Q11) — pair-level rejected; new `partner_unit_members.unit_role` + `project_partner_units` junction.
- **Compute-on-read** derivation via extended `can_see_project()` (Q11b) — no materialised state, no drift.
- **Sectioned Team-tab rendering** with explicit "Abgeleitet (Partner Unit)" subsection + per-row source label (Q13).
- **Derived membership cascades visibility down** via existing path-walk (Q14).
- **Retroactive churn on current state, immutable history** (Q15).
- **No automatic policy inheritance** from parent projects (Q10) — keep cronus's t-138 stance, harden with "Eltern-Politik (zur Information)" panel.
- **Derived authority is opt-in** per (project, unit) via `derive_grants_authority` flag (Q12) — `decision_kind` extends with `derived_peer` for honest audit.
- **Attribution chip** on every aggregated row showing the descendant project (Q19).
- **No depth bound, no materialised counts in v1** (Q17, Q18) — revisit if real telemetry says so.

### Out of scope

- Changing the project tree shape (Client → Litigation → Patent → Case stays).
- Changing RLS direction (descendants visible to ancestors stays as today).
- Cross-project aggregation (rolling rows up across unrelated projects).
- Multi-tenancy / firm-tier hierarchy.
- Generalising the derivation model to non-partner-unit entities (e.g. external counsel teams) — out of v1.

---

## 1. Verified live state (what's actually shipped, vs the issue body and memory)

A design built on a wrong premise rots fast. Three premises in the issue / memory needed verifying against the live codebase before this doc anchors anything on them:

### 1.1 The descendant-aggregation primitive already exists

`internal/services/visibility.go:68` defines `projectDescendantPredicate(alias)`:

```sql
:project_id = ANY(CAST(string_to_array(<alias>.path, '.') AS uuid[]))
```

It matches "this row OR any descendant" against `paliad.projects.path` (text-shaped materialised path; not the `ltree` extension). Already wired in:

- `DeadlineService.ListVisibleForUser` (`deadline_service.go:133`) — when `filter.ProjectID` is set, the predicate is added.
- `DeadlineService.SummaryCounts` (`deadline_service.go:647`) — same.
- `EventService.ListVisibleForUser` (`event_service.go:438`) — same, for the deadlines+appointments union.
- `EventService.SummaryCounts` (`event_service.go:485`) — same.
- `EventService.appointmentBuckets` (visible just below) — same for the appointment side of the bucket math.

The contract is pinned by `internal/services/project_filter_descendants_test.go` (TEST_DATABASE_URL test, t-paliad-124): filtering a Client UUID returns 3 deadlines + 3 appointments across Client / Litigation / Case levels. **So the union endpoints do aggregate**; the bug is somewhere else.

### 1.2 Where the bug actually lives

`/projects/{client_id}` renders deadlines via `frontend/src/client/projects-detail.ts:262`:

```ts
const resp = await fetch(`/api/projects/${id}/deadlines`);
```

That route is `internal/handlers/handlers.go:253` → `handleListDeadlinesForProject` → `DeadlineService.ListForProject`. The service body (`deadline_service.go`):

```sql
SELECT … FROM paliad.deadlines WHERE project_id = $1 ORDER BY due_date ASC, created_at DESC
```

Exact-match, no path walk. Same shape on `AppointmentService.ListForProject` and on `ProjectService.ListProjectEvents` (the audit-trail Verlauf feed via `/api/projects/{id}/events`):

```sql
SELECT … FROM paliad.project_events WHERE project_id = $1 …
```

So three legacy "per-project narrow" service methods bypass the modern subtree-aware union path:

1. `DeadlineService.ListForProject`
2. `AppointmentService.ListForProject`
3. `ProjectService.ListProjectEvents` (used by the Verlauf card on `/projects/{id}` — same exact-match scope)

The bug is in those three paths. The fix isn't "build descendant aggregation"; the primitive is shipped and tested. It's "make the per-project read paths honour it" — see §2.

### 1.3 The PA-derivation premise has no schema today

The issue states: "PAs assigned to a lawyer in a partner-unit auto-derive team membership on every project where that partner-unit is involved" — and asks (Q11): "Where is 'lawyer ↔ PA assignment' stored today?"

Answer: **nowhere.** Verified by reading migration 027 (`027_rename_to_partner_units.up.sql`) and `internal/services/partner_unit_service.go`:

```sql
-- paliad.partner_unit_members (renamed from department_members in 027,
-- table shape unchanged from migration 018):
PRIMARY KEY (partner_unit_id, user_id)
columns: partner_unit_id, user_id, created_at
```

There's no `unit_role` column. There's no `lawyer_id`/`pa_id` pairing table. There's no `project_partner_units` junction either — `paliad.partner_units` and `paliad.projects` have no relation tying a unit to a matter.

So the issue describes a derivation rule whose three structural pieces all need to be added:

1. **A way to mark a partner-unit as involved on a project** (some `project_partner_units(project_id, partner_unit_id, …)` junction).
2. **A way to distinguish PAs from lawyers within a unit** (a role distinction on the unit roster, since the codebase has been deliberate about NOT introducing a firm-wide rank column — see t-paliad-051 / t-paliad-138 three-axis principle).
3. **A way to express the "PA ↔ Lawyer" pairing** if derivation is per-pair rather than per-unit.

This doc proposes shapes for #1 and #2 (§3). It argues that #3 is **not needed** for v1: per-unit derivation is the simpler, more honest model and matches m's plain-English statement once the unit-on-project condition is in place.

### 1.4 The project_teams.role enum, post-t-138

Migration 018 ships:
```
'lead','associate','pa','of_counsel','local_counsel','expert','observer'
```

Migration 054 (cronus's t-138, currently authored on `mai/cronus/inventor-dual-control` @ commit `b3401ec`, **not yet merged to main**) adds `'senior_pa'`. Approval-eligible by default = `{lead, of_counsel, associate}`; `senior_pa`+`pa` only eligible if the per-project policy lowers `required_role`. Strict ladder: `lead(5) > of_counsel(4) > associate(3) > senior_pa(2) > pa(1) > local_counsel/expert/observer(0)`.

The `inherited boolean DEFAULT false` column on `project_teams` (migration 018) exists but is **unused** today — `team_service.go:11` documents it as "reserved for potential future caching of inherited rows. This service does not write inherited=true rows." This becomes load-bearing in §3 if m chooses the materialised-derivation path.

### 1.5 Effective-team direction is currently flipped vs m's brief

`TeamService.ListEffectiveMembers` (`team_service.go:110`) walks **UP** the path: it returns "direct + ancestor team", which is the right semantic for the visibility predicate (a Client member is "effectively" on every descendant Case for `can_see_project`).

m's brief says **"Effective Team of a Client = direct ∪ all descendants' teams ∪ partner-unit-derived"**. That walks **DOWN** (descendants), not UP. The two are different aggregations:

- The **visibility-effective team** of a Case includes that Case's own team plus its ancestors' teams (so a Client lead can see the Case).
- The **display-effective team** of a Client (m's brief) includes that Client's own team plus its descendants' teams plus partner-unit-derived members.

These are not the same set, and the current method's name is too generic. §3 proposes renaming and adding a sibling method.

---

## 2. Sub-design 1 — Surface-by-surface aggregation policy

### 2.1 Default-direction principle

When a surface filters by `project_id`, the question is: does it return only rows whose `project_id == $1` exactly, or rows whose project is `$1` or any descendant?

m's locked constraint: behaviour is surface-specific. The default for surfaces where **the answer isn't obvious** is: **aggregate downward** ("safer no missed information"). Reasons:

- The bug m hit was caused by the narrow default. Missed deadlines are a malpractice exposure surface.
- Aggregation is a strict superset of narrowing — a user can always narrow visually with a "nur direkte" toggle if they want focus.
- Narrowing can quietly hide critical information; aggregation can't.

A surface escapes this default only when narrowing has a concrete justification (editing context, attribution, drilling).

### 2.2 The surface decision table

Each surface either **aggregates** (descendants roll up), **narrows** (exact match only), **toggles** (user picks; default specified), or is **N/A** (surface doesn't take a project filter).

| Surface | Filter axis | Decision | Notes |
|---|---|---|---|
| `/projects/{id}` Fristen section (Q1) | implicit `project_id` | **Aggregate** with attribution | Bug surface. Switch fetch from `/api/projects/{id}/deadlines` to `/api/events?type=deadline&project_id={id}` (already aggregates). Each row carries an attribution chip (§2.4) so the user knows which descendant it lives on. |
| `/projects/{id}` Termine section (Q1) | implicit `project_id` | **Aggregate** with attribution | Same fix shape, /api/events?type=appointment. |
| `/projects/{id}` Aktivität / Verlauf section (Q1) | implicit `project_id` | **Aggregate** with attribution | Today narrow via `ProjectService.ListProjectEvents` — fix to walk descendants via the same path predicate. Attribution chip "auf: Case 14-vs-Müller" on each event row. |
| Project tree sidebar count badges (Q2) | per-node | **Show both** | "Acme Corp (3+12 Fristen)" — direct count + descendant subtree. Single number is ambiguous; both numbers are honest and cheap (one CTE per page load). |
| Dashboard widgets — traffic light, upcoming, recent (Q3) | "all visible projects" | **Stay as-is** | Today scoped to all visible. A per-project subtree filter is a follow-up. The dashboard is a global home, not a project drill-down. |
| `/events` list filtered by `project_id` (Q4) | `?project_id=` | **Aggregate** | Already does (t-124 contract). Add a "nur direkte" toggle for narrowing (§2.5). |
| `/deadlines` and `/appointments` filtered by `project_id` (Q5) | `?project_id=` | **Aggregate** | Same as `/events`. Already does via the same union path. Toggle for narrowing. |
| Search results (Q6) | active project context | **Aggregate** | When the user opens Cmd-K from a `/projects/{id}/...` URL, search across that project's subtree by default (no extra UI). When opened from elsewhere, search is global as today. |
| CalDAV sync (Q7) | per-user calendar feed | **Stay as-is** | Direct memberships only ∪ ancestors (current visibility) — no change. CalDAV is for "what should appear on my external calendar"; that's already the right scope (the user sees a deadline on a Case they're on the team for, regardless of who else's tree it lives in). Adding subtree aggregation here would over-include and cause calendar noise. |
| Email reminders / digest (Q8) | per-user | **Stay as-is** | Same scope rule as CalDAV — the user receives reminders for deadlines they have visibility on, which already includes descendants of any project they're on. No change. |
| `/deadlines/new` and `/appointments/new` (creation) | implicit project | **Narrow** | A new deadline lives on exactly one project — never "on all of these descendants". The project picker on the form is the precise project the row anchors to. |
| Inline-edit forms on detail pages | implicit project | **Narrow** | Same — editing operates on the row's anchor project. |
| Reparent / move form | implicit project | **Narrow** | Operates on the project entity itself, not its descendants. |
| `/admin/audit-log` global (cross-project) | filter chips | **N/A — global** | Already pulls union across `project_events` + sibling sources. Stays global, optional `project_id` filter narrows to subtree (aggregate). |
| `/admin/team` (firm-wide directory) | N/A | **N/A** | Lists all users; no project filter. |

### 2.3 Why CalDAV and email reminders stay as-is (Q7, Q8)

Surface intent matters. CalDAV and reminders are **personal information feeds** — the user's calendar shows things the user is responsible for. The visibility predicate already gives them coverage of every descendant of any project they're on. Adding subtree aggregation on top would create double-rollup: a Client lead would receive every Case-level deadline reminder twice (once via her own membership, once via subtree rollup). Worse: a Client lead would receive notifications for trivial Case-level scheduling she has no operational role in, just because she's on the parent.

The dashboard rule (Q3) is the same shape: it's an "all my work today" home, not a project drill-down. Stays scoped to "all visible to me."

The bright line is: **list/detail surfaces of a project node** aggregate downward; **personal/global surfaces** keep their scope as-is.

### 2.4 Attribution badge (Q19)

When a `/projects/{client_id}` Fristen list shows 47 deadlines that mostly live on descendants, each row needs to disclose where it actually anchors. Proposed: a chip in the "Projekt" column (or alongside the title) with the descendant project's title:

```
⚠ Erwiderung Hauptverhandlung    auf: Case 14-vs-Müller    23.05.2026
Stellungnahme zur Replik         (direkt)                  29.05.2026
```

CSS: small grey chip, click navigates to that descendant's page. The chip is suppressed if the row's project is the current page's project ("(direkt)" stays optional). Pattern reusable across all `/projects/{id}/*` aggregating surfaces.

i18n keys (DE primary, EN secondary):

- `aggregation.attribution.on` — "auf: {title}" / "on: {title}"
- `aggregation.attribution.direct` — "direkt" / "direct"
- `aggregation.toggle.subtree` — "Inkl. Unterprojekte" / "Incl. sub-projects"
- `aggregation.toggle.direct_only` — "Nur direkt" / "Direct only"
- `aggregation.subtree_count` — "{direct} direkt + {descendants} aus Unterprojekten" / "{direct} direct + {descendants} from sub-projects"

### 2.5 Toggle pattern (narrow vs aggregate)

For surfaces where both modes are useful (`/events`, `/deadlines`, `/appointments`, `/projects/{id}/*` sections), a small toggle in the filter bar lets the user flip:

```
[ Inkl. Unterprojekte ] (default)    [ Nur direkt ]
```

URL state: `?subtree=true` (default omitted) | `?subtree=false`. Backend respects via a new `SubtreeOnly bool` flag on the existing filter structs — when false, the service uses `f.project_id = :project_id` (exact) instead of `projectDescendantPredicate("p")`. Cheaper than the path-walk; the predicate only changes shape, not cost.

The `/deadlines/new` / inline-edit forms have no toggle — they're narrow by definition.

### 2.6 Project tree sidebar dual counts (Q2)

The project tree page (`/projects` Tree view, t-paliad-028 / 056) already renders a count badge per node. Today: direct deadline count. Proposal: render two values when they differ:

```
Acme Corp                           (3 + 12)
├── Acme v. Foo (Litigation)        (1 + 9)
│    └── EP1234 B1 (Patent)         (0 + 8)
│         └── 14-vs-Müller (Case)   (8)
```

Where format is `(direct)` when no descendants OR `(direct + descendants)` when both. Single number when descendants is zero. The data shape is already produced by `ProjectService.GetTree` — it only needs the secondary count from a single CTE that walks each subtree:

```sql
WITH RECURSIVE counts AS (
  SELECT p.id, p.path,
    (SELECT COUNT(*) FROM paliad.deadlines f
       WHERE f.project_id = p.id AND f.status = 'pending') AS direct_count,
    (SELECT COUNT(*) FROM paliad.deadlines f
       JOIN paliad.projects p2 ON p2.id = f.project_id
       WHERE p.id = ANY(string_to_array(p2.path, '.')::uuid[])
         AND f.status = 'pending') AS subtree_count
   FROM paliad.projects p
   WHERE <visibilityPredicate>
)
```

Subtree count includes self by definition (`p.id` appears in its own path's last label). Direct = self only. Difference = pure-descendant.

### 2.7 Per-surface defaults — formatting summary

```
Aggregating surfaces (with toggle):  /events, /deadlines, /appointments, /projects/{id}/{section}
Aggregating surfaces (no toggle):    project tree dual counts, attribution chips
Narrowing surfaces:                  Create / inline-edit forms, reparent
Personal/global (no change):         Dashboard, CalDAV, email reminders, /admin/audit-log
Search:                              Subtree default when in project context, global otherwise
```

### 2.8 Performance and scale (Q17, Q18)

`projectDescendantPredicate` is a `<@`-style ancestor check on a text-materialised path with `text_pattern_ops` (migration 018) — sub-millisecond for any realistic tree (paliad's largest expected tree is one Client with ~100 Cases). The path-walk uses `string_to_array(... )::uuid[]` then `= ANY(...)`, which Postgres planner reduces to a bitmap-scan on `project_teams.project_id`. The shipped t-124 test runs cleanly against the live DB.

**No depth bound.** m's brief is silent on this and the schema doesn't enforce one. We don't impose a depth cap unless a real perf bottleneck shows up (it hasn't).

**No materialised counts in v1.** The dual-count CTE in §2.6 is one query per page-load of `/projects` Tree view; profile it before adding caching. The dashboard summary already uses CTEs and stays performant.

**Escape hatch (Q18):** if a tree gets pathologically large (1000+ nodes), the read path's bottleneck would show up first in `ListVisibleForUser` joining over the tree. Mitigation if it ever bites: a `paliad.project_subtree_counts` materialised view refreshed on insert/update/delete via trigger. **Not in v1** — premature optimisation.

### 2.9 Implementation sketch — read-path fix

Three legacy methods to fix:

1. **`DeadlineService.ListForProject`** → either remove it (callers redirect to `EventService.ListVisibleForUser`) or switch its body to use `projectDescendantPredicate` and add a `direct_only bool` arg. Recommend: remove + redirect callers (1 site: `handleListDeadlinesForProject`). Same for `AppointmentService.ListForProject`.
2. **`ProjectService.ListProjectEvents`** → switch the WHERE clause from `project_id = $1` to the descendant predicate. Add a `direct_only` arg for the rare narrow case. The audit-trail Verlauf is the right place for subtree aggregation (project events from descendants are part of the matter's history). Default = aggregate.
3. **Frontend `projects-detail.ts:262/271/216`** → switch the three fetch calls to the union endpoints (`/api/events?type=deadline&project_id=`, `/api/events?type=appointment&project_id=`, `/api/projects/{id}/events?subtree=true`).

Each row in the response already carries `project_id`. The frontend reads it; if `row.project_id !== currentProjectID`, render the attribution chip with the row's project title (already in the response payload via the `…WithProject` projection used by `ListVisibleForUser`).

This is mechanical work, ~300-400 LoC across 4 files. No schema change.

---

## 3. Sub-design 2 — Effective team semantics + partner-unit derivation

### 3.1 The two distinct "effective team" senses

m's brief: **"Effective Team of a Client" = direct ∪ all descendants' teams ∪ partner-unit-derived PAs.** This is a **display-time aggregation** — it answers "who's on this matter, when I'm looking at the Client?".

The current `TeamService.ListEffectiveMembers` answers a different question: it returns "direct + ancestor team", which is the visibility-side semantic ("who can see this Case", from the perspective of a child node). Both are useful; both should exist. Naming proposal:

- **`TeamService.ListEffectiveMembers(projectID)`** → keep current behaviour, **rename in name only** to `ListVisibilityEffectiveMembers` so the semantic is explicit. Used by visibility-direction queries and by the existing project detail "Inherited from parent" team UI. Rename is mechanical (1 method, 1 caller in `team_pages.go`).
- **`TeamService.ListSubtreeMembers(projectID)`** (new) → returns "direct + descendants + partner-unit-derived". This is the m-brief "Effective Team of a Client". Used by the new aggregating "Project Team" overview on `/projects/{id}`.

Rendering: the project detail page's Team tab shows three sections, in order:

1. **Direkt** — direct memberships on this project.
2. **Geerbt aus Eltern-Projekten** — ancestor-inherited (today's "Inherited" rows). Only visible on non-root projects.
3. **Aus Unterprojekten** — descendant-inherited (NEW). Only visible on projects that have descendants. Each row carries the descendant project title in the "Quelle" column.
4. **Abgeleitet (Partner Unit)** — derived from a partner-unit on this project (NEW, see §3.2). Each row carries `Aus: <Unit-Name>` annotation.

Each section sorts by role then name; sections render only if they have rows.

### 3.2 Partner-unit derivation — the missing schema

The issue says (Q11): "PAs are automatically part of teams when a partner unit is involved." For this to work, two pieces of data are needed that don't exist today:

- A way to mark **a unit as involved on a project**.
- A way to identify **which members of that unit derive** (m's intent: PAs only, not all members).

#### 3.2.1 Schema addition: `paliad.project_partner_units` (NEW)

```sql
CREATE TABLE paliad.project_partner_units (
    project_id      uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
    partner_unit_id uuid NOT NULL REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
    -- Roles in the unit that auto-derive onto the project team.
    -- Default {'pa','senior_pa'} — m's locked rule.
    -- Can be widened per project (e.g. ['pa','senior_pa','attorney']) if a
    -- specific matter needs the whole unit, or narrowed (e.g. just ['pa']).
    derive_unit_roles text[] NOT NULL DEFAULT ARRAY['pa','senior_pa'],
    attached_at  timestamptz NOT NULL DEFAULT now(),
    attached_by  uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
    PRIMARY KEY (project_id, partner_unit_id)
);

CREATE INDEX project_partner_units_unit_idx
    ON paliad.project_partner_units (partner_unit_id, project_id);
```

This is a thin, additive junction. RLS = `paliad.can_see_project(project_id)` for SELECT; INSERT/UPDATE/DELETE gated to project lead OR global_admin (consistent with how the team roster is managed today).

#### 3.2.2 Schema addition: `paliad.partner_unit_members.unit_role` (NEW column)

```sql
ALTER TABLE paliad.partner_unit_members ADD COLUMN unit_role text NOT NULL DEFAULT 'attorney'
    CHECK (unit_role IN ('lead','attorney','senior_pa','pa','paralegal'));
```

This is the **only place a firm-rank concept enters the schema**. It is intentionally **scoped to the unit, not to the user**. The same human can be `attorney` in Müller-Unit and `lead` in Schmidt-Unit — though in practice that's rare. The column is unit-scoped because:

- Per-unit roles preserve the three-axis principle (t-paliad-051, t-paliad-138). It is not a global firm-wide rank; it is a per-unit organisational role.
- A user's `paliad.users.job_title` (free-text display) and `paliad.users.global_role` (`standard | global_admin`) stay untouched. Three-axis principle holds: job_title, global_role, project_role — unit_role is a fourth axis but it's a unit-property, not a user-property.
- Derivation can target specific tiers (default: PAs) without committing to a firm-wide rank.

Initial values: every existing row defaults to `'attorney'`. Admins re-tier as needed via the existing `/admin/partner-units` UI (small follow-up — see §3.5). Because `attorney` is not in the default `derive_unit_roles`, no existing membership starts deriving anywhere on day one. Opt-in by re-tagging.

#### 3.2.3 Why not a `lawyer ↔ PA assignment` table?

The issue's wording mentions "PAs assigned to a lawyer." A literal reading would add `paliad.user_pa_assignments(lawyer_id, pa_id, …)`. **Recommended against** for three reasons:

1. **The data doesn't exist.** No table, no spreadsheet, no source-of-truth captures "Anna PA reports to Bert Lawyer" today. Adding the schema means asking m to populate ~30 PAs' lawyer-assignments by hand.
2. **It double-encodes the unit.** If Anna PA is in Müller-Unit, and Bert Lawyer is in Müller-Unit, and the matter has Müller-Unit attached, you don't need the explicit pairing — the unit captures the affiliation already.
3. **The derivation rule m wants is unit-level, not pair-level.** "Müller-Unit is on the matter" → "all Müller-Unit PAs derive". The pair-level form would add per-PA precision that creates more friction (every PA reassignment touches projects) for marginal value.

If a project team genuinely needs PA-level precision (e.g. Anna PA derives but Bert PA doesn't), the right tool is **explicit team membership**: add Anna directly to the project team as `role='pa'`. The derivation mechanism is a default; explicit membership always wins.

**Open Q for m:** confirm unit-level derivation is the intent, vs pair-level? (Recommend: unit-level. See §6 Q11.)

### 3.3 Visibility semantics — display vs authority (Q9)

m's Q9: "(a) display-time aggregation only, OR (b) does it grant visibility / authority somewhere new?"

The current state today: `paliad.can_see_project()` walks ancestors; a user on Client A can see all descendants of A. So a derived member of a Client (via Müller-Unit) automatically gets visibility of the entire subtree, **provided their derived membership materialises into something `can_see_project` can read**.

Two architectural options for materialisation:

#### Option A — Materialise derived rows into `paliad.project_teams`

Use the existing `paliad.project_teams.inherited boolean DEFAULT false` column (currently unused, reserved). A derivation insert / sync writes `inherited=true` rows tagged with a new `derivation_source jsonb` column.

```sql
ALTER TABLE paliad.project_teams ADD COLUMN derivation_source jsonb;
-- e.g. {"type": "partner_unit", "partner_unit_id": "<uuid>", "synced_at": "..."}
-- NULL on direct rows, populated on derived rows.
```

Sync is service-layer, in the same tx as any of:
- INSERT on `project_partner_units` → INSERT a row into `project_teams` for every derive-eligible unit member.
- DELETE on `project_partner_units` → DELETE the matching derived rows.
- INSERT on `partner_unit_members` (when unit is on N projects) → INSERT N derived rows.
- DELETE on `partner_unit_members` → DELETE matching derived rows.
- UPDATE on `partner_unit_members.unit_role` → INSERT/DELETE based on whether the new role is in `derive_unit_roles`.

Pros:
- `can_see_project` works unchanged (derived rows are real `project_teams` rows).
- Read-side queries (every list endpoint that JOINs `project_teams`) need zero changes.
- The dashboard / agenda / any visibility-keyed query stays fast.

Cons:
- Sync is many-to-many: 1 unit on 5 projects with 8 PAs = 40 derived rows on a single attach. Manageable but not trivial.
- Drift risk: if a sync misses (transient FK error, concurrent write race), the derived state diverges from the source-of-truth. Need a periodic reconciliation job.
- Admin reading direct memberships sees a polluted list unless the UI filters `derivation_source IS NULL`.

#### Option B — Compute on read (extend `can_see_project()`)

Don't materialise. Extend the SQL function to UNION in derived members:

```sql
CREATE OR REPLACE FUNCTION paliad.can_see_project(target_id uuid) RETURNS boolean AS $$
  -- existing branches (global_admin, ancestor team membership)
  -- + new branch: direct or ancestor partner-unit attached, with derive role match
  EXISTS (
    SELECT 1 FROM paliad.project_partner_units ppu
      JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
      JOIN paliad.projects p ON p.id = target_id
     WHERE pum.user_id = auth.uid()
       AND pum.unit_role = ANY(ppu.derive_unit_roles)
       AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
  )
$$ LANGUAGE SQL STABLE;
```

Plus the Go mirror in `services.visibilityPredicate` (and `visibilityPredicatePositional`).

Pros:
- Zero sync. The source-of-truth is `partner_unit_members` + `project_partner_units`; no derived state to keep in sync.
- No drift possible.
- Removing a unit member instantly removes their derived membership everywhere.

Cons:
- The visibility predicate gets larger (one more EXISTS branch). Read-cost increases on every project-scoped query.
- Index strategy: `paliad.partner_unit_members(user_id)` (already there as `partner_unit_members_user_idx`); plus `paliad.project_partner_units(partner_unit_id, project_id)` (new index above). Both small tables; should stay sub-ms.
- Listing the project team for display still needs the Go-side compute (UNION in the materialised query body). But that's already what `ListSubtreeMembers` does — it's not a regression.

#### Recommendation: Option B (compute on read)

Cleaner architecture, no drift, no sync. The read-cost is small (two more EXISTS branches in the visibility predicate, both backed by existing indexes). Over the lifetime of the system, **every** sync-based system eventually drifts; computed-on-read is honest by construction.

The downside (slightly slower visibility queries) is mitigated by the fact that paliad's hot path (list-with-visibility) already pays for the existing path-walk; one more EXISTS is in the noise.

**Open Q for m:** confirm Option B (compute on read) over Option A (materialise)? (Recommend: B. See §6 Q11b.)

### 3.4 Authority of derived members (Q12)

m's Q12: "If a derived PA is on a project at level=pa, can they approve?"

Two stances:

- **Strict:** derivation = visibility-only. Derived members can read but cannot mutate / approve. Authority requires **explicit** project_teams membership.
- **Permissive:** derivation = full membership. A derived PA who's on a project where the policy is `required_role='pa'` can approve — same as if they were directly added.

**Recommendation: strict by default; permissive opt-in.**

Reasoning:

- Derivation is a **convenience layer** — saving the firm from re-staffing every PA on every matter. Convenience layers shouldn't grant authority silently.
- The 4-eye design (t-paliad-138) is about accountability. An approval signed by a derived member who didn't even know they were on the matter is a worse signature than no approval.
- The honesty principle (Q13 — derivation visible everywhere) is incoherent with silent authority. If the user can sign off, they should know they're on the team.
- **Mutation** rights (create / update / delete) are similar — derived members can read but not mutate by default.

Schema: a per-row column on `project_partner_units`:

```sql
ALTER TABLE paliad.project_partner_units ADD COLUMN derive_grants_authority boolean NOT NULL DEFAULT false;
```

When `false`: derived members are visibility-only, omitted from the approver-eligibility query and from mutation-allowed lists.
When `true`: derived members are treated as direct members for both visibility and authority. Their effective `project_teams.role` for the approval ladder is the **map** of their `unit_role`:

| `unit_role` | Mapped `project_teams.role` (for approval-ladder purposes) |
|---|---|
| `lead` | `lead` |
| `attorney` | `associate` |
| `senior_pa` | `senior_pa` |
| `pa` | `pa` |
| `paralegal` | `observer` (level 0 = ineligible to approve) |

Service-layer helper: `effectiveProjectRole(userID, projectID)` returns `(role, source)` where `source ∈ {direct, ancestor, descendant, derived}`. Used by the approval canApprove check (§4.2).

The strict-default is consistent with how RLS in paliad treats edge cases (deny by default, opt-in to grant). It also makes the "Aus: Müller-Unit" annotation unambiguous: if the row says "derived", the user is reading-only unless `derive_grants_authority=true`, in which case the badge upgrades to "abgeleitet & stimmberechtigt".

**Open Q for m:** confirm strict default + per-unit-on-project opt-in for authority? (See §6 Q12.)

### 3.5 Display annotation (Q13)

The honesty rule: every derived member is annotated everywhere their membership shows. Three patterns:

#### 3.5.1 Project team overview (Team tab on /projects/{id})

Sectioned rendering (§3.1). Derived rows under "Abgeleitet (Partner Unit)" with full annotation:

```
Anna Schmidt        PA           über Müller-Unit (5 Projekte gemeinsam)    [Sicht]
Bert Müller         Senior PA    über Müller-Unit                            [Sicht & 4-Augen]
```

Where `[Sicht]` and `[Sicht & 4-Augen]` are small badges for visibility-only vs authority-granted. Right column shows the unit name; hover/click opens unit details. Badge colour matches the derivation chip palette (subtle grey-blue, distinct from direct-team and ancestor-inherited rows).

#### 3.5.2 Inline annotation on entity rows

When a deadline/appointment row is approved by a derived member, the Verlauf line renders the source explicitly:

```
Frist Erwiderung — genehmigt von Anna Schmidt (PA, abgeleitet aus Müller-Unit)  2026-05-12
```

Not just "von Anna Schmidt" — the audit trail must disclose the derivation. This is a Verlauf rendering concern (`translateEvent` in `projects-detail.ts`); the `decision_source` field on the approval_request row carries the chain.

#### 3.5.3 /team directory grouping

The existing `/team` directory page groups by office / partner_unit. Derived-membership-on-project doesn't show here (the directory is firm-wide). No change needed.

### 3.6 Cascading + hierarchy interaction (Q14)

Q14: "If Anna PA derives onto Client A through Müller-Unit, does Anna inherit visibility of all descendants of Client A?"

**Yes.** The visibility predicate walks ancestors (already does). Once Anna has a path-membership on Client A's row (via the derivation predicate in Option B, §3.3), descendants are visible by the existing rule:

```
ppu.project_id = ANY(string_to_array(target.path, '.')::uuid[])
```

Where `target` is any descendant of Client A. Client A's id appears in every descendant's path (path includes self), so the predicate matches.

This is the **right** behaviour: derivation makes you a team member at the level it derives, and visibility then walks down per the existing rules. Computing derivation per-descendant is unnecessary.

However: **subtree aggregation in §2's Team-tab "Aus Unterprojekten" subsection should NOT include derived members of descendants in the parent's view.** Reasoning: the parent's team overview should show "who's directly on this Client (the partner unit involvement)" + "who's on a Case below me (real explicit staffing)". Cascading derived members up the tree would show every PA in every unit attached anywhere in the subtree as "in the Client team", which dilutes the signal.

So the rendering rule for the parent's display:

- **Direct + ancestor** rows: as today.
- **Descendant** rows: include direct-team rows of descendants (explicit staffing on a Case shows up on the Client view). **Exclude** derived rows of descendants (those don't surface up).
- **Derived (this project)** rows: this project's own `project_partner_units` derivations.

This preserves "explicit staffing wins" as the structural read.

### 3.7 Removal / churn (Q15)

Q15: "When Anna is reassigned to a different lawyer, does her derived membership lapse on all old projects retroactively, or only on new projects going forward?"

The pair-level "PA-assigned-to-Lawyer" model is rejected (§3.2.3), so the sub-question evaporates: there is no per-pair assignment to reassign. The unit-level model has cleaner churn semantics:

- **Anna leaves Müller-Unit** (`DELETE FROM partner_unit_members WHERE user_id=anna AND partner_unit_id=mueller`) → Anna's derivation evaporates everywhere immediately (computed-on-read; or sync-on-write if Option A). All projects where Müller-Unit is attached drop her from derivation.
- **Anna's `unit_role` changes** (e.g. `pa` → `attorney`) → if `attorney` is not in `derive_unit_roles` for a project, her derivation drops there. Per-project; some may keep her, others drop.
- **Müller-Unit detached from Project X** → all unit members lose their derivation on X.

**Audit trail:** every change to `paliad.partner_unit_members` already emits a `paliad.partner_unit_events` row (member_added / member_removed) per migration 027. No new audit table needed. We add four new event_types on that side: `derivation_started_on_project`, `derivation_ended_on_project`, `unit_attached_to_project`, `unit_detached_from_project`. These show up in the user's profile timeline + the project's Verlauf (cross-emission to `paliad.project_events`).

**Retroactivity:** churn affects current state only — past approvals signed by Anna stay valid (the audit row records `decision_source: derived`). She doesn't lose the signature; she just stops being able to make new ones.

This is the natural, honest semantic — "you stop deriving when the chain stops being true," but the historical record is immutable.

### 3.8 Service shape

```go
// internal/services/derivation_service.go (NEW)

type DerivationService struct {
    db          *sqlx.DB
    projects    *ProjectService
    units       *PartnerUnitService
}

// AttachUnitToProject creates a project_partner_units row (with optional
// custom derive_unit_roles override). Emits unit_attached_to_project event.
func (s *DerivationService) AttachUnitToProject(ctx, callerID, projectID, unitID, opts AttachOpts) error

// DetachUnitFromProject deletes the row, emits unit_detached_from_project.
func (s *DerivationService) DetachUnitFromProject(ctx, callerID, projectID, unitID) error

// DerivedMembersForProject computes the derived list at read time (Option B).
// Returns rows enriched with unit name + unit_role + derives_authority flag.
func (s *DerivationService) DerivedMembersForProject(ctx, callerID, projectID) ([]DerivedMember, error)

// EffectiveProjectRole — used by ApprovalService.canApprove and elsewhere.
// Returns (role, source) where source is one of: direct | ancestor | descendant | derived.
// Resolves direct first, then derived (only if derive_grants_authority=true on the
// matching project_partner_units row), then ancestor.
func (s *DerivationService) EffectiveProjectRole(ctx, userID, projectID uuid.UUID) (string, string, error)
```

The service is small; the bulk of the logic is the visibility predicate extension and the team-listing UNION. ~150-200 LoC.

Frontend: `/admin/partner-units/{id}` gains a "Mitglieder" tab where each row has a `unit_role` select. `/projects/{id}/settings/team` gains a "Partner Units" section listing attached units, with attach/detach buttons + a per-unit toggle for "Stimmrecht abgeben (4-Augen)".

### 3.9 The "Effective Team of a Client" definition — pinned

**Display-effective team** of project P (used by the new `ListSubtreeMembers` and the project detail Team tab):

```
direct_team(P)
  ∪ ancestor_team(P)
  ∪ descendant_direct_team(P)         -- explicitly staffed on any descendant of P
  ∪ derived_members(P, depth=self)    -- partner-unit-attached on P only
```

Where:
- `direct_team(P)` = `project_teams WHERE project_id = P.id`.
- `ancestor_team(P)` = `project_teams WHERE project_id IN ancestors(P.id)`.
- `descendant_direct_team(P)` = `project_teams WHERE project_id IN descendants(P.id) AND derivation_source IS NULL` (or, in Option B world: just `WHERE project_id IN descendants(P.id)`).
- `derived_members(P)` = users from any unit attached to P whose `unit_role IN derive_unit_roles`.

**Visibility-effective** is a different set (used by `can_see_project`):

```
direct_team(P) ∪ ancestor_team(P) ∪ derived_members(any ancestor of P, P inclusive)
```

So a member of any ancestor's team OR a derived member of any ancestor (or P itself) can see P. Descendant-direct-team is NOT in the visibility set — that's the existing direction (a Client team member sees Cases, but a Case team member doesn't automatically see the Client header data unless they're explicitly on the Client team or visible via path-walk-ancestors which they won't be from a descendant).

These two sets are intentionally different. Display answers "who's on this matter to a viewer of P?"; visibility answers "who can read P?". Both are honest.

---

## 4. Sub-design 3 — Approval-policy interaction with hierarchy + derivation

This section coordinates with cronus's t-paliad-138 design (`docs/design-approvals-2026-05-06.md`, branch `mai/cronus/inventor-dual-control`). Two cross-cuts: hierarchy (Q10) and derivation (Q12).

### 4.1 Q10 — does a Client-level policy inherit down to a Case?

**t-138's locked answer: NO inheritance.** Each project authors its own policy independently, with an "Aus Eltern-Projekt übernehmen" button as a one-shot copy. Reasons cronus gave: (a) reparenting would silently change behaviour, (b) per-project authoring is the right default, (c) path-walking adds complexity for marginal value.

**This design's recommendation: keep no-auto-inheritance, but harden the UX.** Two reasons to keep cronus's call:

- **Predictability over convenience.** When a deadline is created on a Case, the user authoring it should see the policy that applies, not a hidden inherited rule. Inheritance with overrides creates "where is this rule defined?" confusion that compliance hates.
- **Reparenting cost.** Moving a Case from Litigation A to Litigation B silently re-policies the Case if ancestors inherit. Today reparent is a project-detail action (t-paliad-018); we don't want it to also flip the approval gate.

What we add on top of cronus's design (UX hardening):

#### 4.1.1 Policy stack visualisation

On `/projects/{id}/settings/approvals`, render the project's own 8-cell policy table AND the inherited-from-ancestors stack as an explicit "Eltern-Politik (zur Information)" panel:

```
DIESES PROJEKT — Acme v. Foo Patent Verfahren
Frist:    create=associate  update=associate  complete=associate  delete=lead
Termin:   —                 update=associate  —                   —

ELTERN-POLITIK (KEIN AUTOMATISCHER EINSCHLUSS) — Acme v. Foo Litigation
Frist:    create=lead       …
                                                          [Übernehmen]

ELTERN-POLITIK — Acme Corp Client
Frist:    create=lead       …
                                                          [Übernehmen]
```

The "Übernehmen" button is the one-shot copy from cronus's §5.3. The display is informational: nothing inherits silently, but the parent's authoritarian decisions are visible to the child author.

#### 4.1.2 The "policy as default" alternative

If m later wants real inheritance, a clean shape is **"the closest ancestor's policy applies if this project has no row"**:

```
For a request on (project=Case, entity=deadline, lifecycle=update):
  walk path from Case up: Case → Patent → Litigation → Client → root
  return the first project row that has a matching approval_policies row
  (else: no approval needed)
```

Service-layer implementation is one CTE. Trade-off: silent rules. Reparenting changes behaviour. Authoring becomes ambiguous ("does this policy override an inherited one or define a new one?").

**Recommendation for v1: don't inherit silently.** Use the policy-stack panel for visibility. Re-evaluate in 6 months if firm experience says inheritance is needed.

### 4.2 Q12 — does derivation carry approval authority?

Per §3.4, **strict default**: derived members are visibility-only. Authority requires `derive_grants_authority=true` on the `project_partner_units` row.

When `derive_grants_authority=true`, the `canApprove(callerID, projectID, requiredRole)` helper from t-138 §3.4 needs an extension:

```go
func (s *ApprovalService) canApprove(ctx, callerID, projectID, requiredRole, requesterID) (bool, kind string, err error) {
    if callerID == requesterID {
        return false, "", ErrSelfApprovalBlocked
    }
    user, _ := s.users.GetByID(ctx, callerID)
    if user.GlobalRole == "global_admin" {
        return true, "admin_override", nil
    }
    role, source, err := s.derivation.EffectiveProjectRole(ctx, callerID, projectID)
    if err != nil { return false, "", err }
    if role == "" {
        return false, "", nil  // not on team in any form
    }
    if levelOf(role) >= levelOf(requiredRole) {
        kind := "peer"
        if source == "derived" { kind = "derived_peer" }
        return true, kind, nil
    }
    return false, "", nil
}
```

`decision_kind` enum on `paliad.approval_requests` extends to: `peer | admin_override | derived_peer`. The third value is honest about the chain — Verlauf rendering can render it as "Genehmigt von Anna (PA, abgeleitet aus Müller-Unit)". The audit trail discloses every derivation.

Single-qualified-approver deadlock count (t-138 §4.2) gets the same extension: count derived authority-granting members alongside direct ones.

**Coordination consequence for cronus's t-138 implementation:** §3.4's `canApprove` and §7.4's inbox SQL both need to be widened to honour `EffectiveProjectRole`. The change is small (re-use `DerivationService.EffectiveProjectRole`) but it's a coordination point: t-138 should land first (without derivation awareness), then this design's coder shift extends t-138's helpers. Or: t-139 lands first (schema only, no behaviour) and t-138 lands on top of it.

**Recommended phasing (§7):** ship t-138 as designed (no derivation), then extend `canApprove` here when t-139 lands.

### 4.3 What about deadlines that live on a Case but the policy is on the Client?

This is the same as Q10 — no inheritance, the Case authors its own policy. If a Case has no `(deadline, update)` row, no approval is needed for date edits on that Case (today's behaviour). The Client's policy is informational on the Case's settings panel but doesn't apply.

If the firm's policy is "all Cases under Acme need 4-eye on date edits", the right v1 workflow is: when the Litigation is created, propagate-and-author the policy onto each Case (manual, one-shot). The "Übernehmen" button supports this. Future automation: a "Auf alle Unterprojekte anwenden" button on the parent's policy authoring page that bulk-creates rows on every direct + descendant project. Out of scope for v1.

### 4.4 Approval-RLS interaction

Per t-138 §9: `paliad.approval_requests` RLS = `paliad.can_see_project(project_id)`. Since `can_see_project` is extended (Option B) to include partner-unit derivation, derived members CAN read approval requests on projects they derive into. This is desirable — they can see the queue, just can't act on it (unless `derive_grants_authority=true`).

The inbox SQL from t-138 §7.4 needs the eligibility branch widened to honour derivation:

```sql
SELECT ar.* FROM paliad.approval_requests ar
  JOIN paliad.projects p ON p.id = ar.project_id
 WHERE ar.status = 'pending'
   AND ar.requested_by != $callerID
   AND <visibilityPredicate>(p) for callerID
   AND (
     -- caller is global_admin
     EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $callerID AND u.global_role = 'global_admin')
     OR
     -- caller has eligible direct role (own + ancestors)
     EXISTS (SELECT 1 FROM paliad.project_teams pt
              WHERE pt.user_id = $callerID
                AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
                AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role))
     OR
     -- caller has eligible derived role (only on projects with derive_grants_authority=true)
     EXISTS (SELECT 1 FROM paliad.project_partner_units ppu
              JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
              WHERE pum.user_id = $callerID
                AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
                AND ppu.derive_grants_authority = true
                AND pum.unit_role = ANY(ppu.derive_unit_roles)
                AND paliad.approval_role_level(
                      paliad.approval_role_from_unit_role(pum.unit_role)
                    ) >= paliad.approval_role_level(ar.required_role))
   )
 ORDER BY ar.requested_at ASC;
```

`paliad.approval_role_from_unit_role` is a small SQL function:

```sql
CREATE FUNCTION paliad.approval_role_from_unit_role(unit_role text) RETURNS text LANGUAGE SQL IMMUTABLE AS $$
  SELECT CASE unit_role
    WHEN 'lead'      THEN 'lead'
    WHEN 'attorney'  THEN 'associate'
    WHEN 'senior_pa' THEN 'senior_pa'
    WHEN 'pa'        THEN 'pa'
    ELSE                  'observer'
  END
$$;
```

This goes into migration 055 (this design's schema) and is referenced from t-138's inbox handler when t-139 lands.

---

## 5. Schema additions (migration 055)

A clean follow-up migration after t-138's 054 lands. Single tx, modest footprint.

```sql
-- ============================================================================
-- 055_hierarchy_aggregation.up.sql
-- Adds: project↔unit junction, unit-role distinction, derivation predicates,
-- and small helper functions used by t-138's approval SQL.
-- ============================================================================

-- 1. Unit-role distinction on partner_unit_members.
ALTER TABLE paliad.partner_unit_members ADD COLUMN unit_role text NOT NULL DEFAULT 'attorney'
    CHECK (unit_role IN ('lead','attorney','senior_pa','pa','paralegal'));

-- 2. Project ↔ partner_unit junction (the "involved on a project" axis).
CREATE TABLE paliad.project_partner_units (
    project_id              uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
    partner_unit_id         uuid NOT NULL REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
    derive_unit_roles       text[] NOT NULL DEFAULT ARRAY['pa','senior_pa'],
    derive_grants_authority boolean NOT NULL DEFAULT false,
    attached_at             timestamptz NOT NULL DEFAULT now(),
    attached_by             uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
    PRIMARY KEY (project_id, partner_unit_id)
);

CREATE INDEX project_partner_units_unit_idx
    ON paliad.project_partner_units (partner_unit_id, project_id);

ALTER TABLE paliad.project_partner_units ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_partner_units_select
    ON paliad.project_partner_units FOR SELECT
    USING (paliad.can_see_project(project_id));
CREATE POLICY project_partner_units_write
    ON paliad.project_partner_units FOR ALL
    USING (
      EXISTS (SELECT 1 FROM paliad.users u
               WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
      OR EXISTS (SELECT 1 FROM paliad.project_teams pt
                  WHERE pt.user_id = auth.uid()
                    AND pt.project_id = project_partner_units.project_id
                    AND pt.role = 'lead')
    );

-- 3. Helper: approval_role_from_unit_role (used by t-138 inbox SQL).
CREATE FUNCTION paliad.approval_role_from_unit_role(unit_role text) RETURNS text LANGUAGE SQL IMMUTABLE AS $$
    SELECT CASE unit_role
        WHEN 'lead'      THEN 'lead'
        WHEN 'attorney'  THEN 'associate'
        WHEN 'senior_pa' THEN 'senior_pa'
        WHEN 'pa'        THEN 'pa'
        ELSE                  'observer'
    END
$$;

-- 4. Extend can_see_project() to honour partner-unit derivation.
--    Replaces migration 023's body. Identical structure plus one EXISTS branch.
CREATE OR REPLACE FUNCTION paliad.can_see_project(target_id uuid) RETURNS boolean
LANGUAGE SQL STABLE AS $$
    SELECT EXISTS (
        SELECT 1 FROM paliad.users u
         WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
    ) OR EXISTS (
        SELECT 1 FROM paliad.project_teams pt
          JOIN paliad.projects p ON p.id = target_id
         WHERE pt.user_id = auth.uid()
           AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
    ) OR EXISTS (
        SELECT 1 FROM paliad.project_partner_units ppu
          JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
          JOIN paliad.projects p ON p.id = target_id
         WHERE pum.user_id = auth.uid()
           AND pum.unit_role = ANY(ppu.derive_unit_roles)
           AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
    );
$$;

-- 5. Audit table extension — partner_unit_events gets two new event types
--    (unit_attached_to_project, unit_detached_from_project). No DDL change;
--    the events table already accepts arbitrary event_type strings.
```

Down migration drops the new function, restores the migration-023 `can_see_project` body, drops `paliad.project_partner_units`, drops `paliad.partner_unit_members.unit_role`. If any project has `project_partner_units` rows, the down would orphan those — fail loudly (intentional, mirrors t-138's down strategy).

### 5.1 Bug-fix-only migration (if m wants the bug fix decoupled)

If m wants the `/projects/{id}` "Keine Fristen" bug fix to land standalone before any of the §3 / §4 work, that's a code-only change (no schema). Just:

1. Switch `projects-detail.ts` fetches to the union endpoints.
2. Update `ProjectService.ListProjectEvents` to use `projectDescendantPredicate`.
3. Optionally add the `direct_only` toggle.

That's a 1-PR fix, no migration. The §3 / §4 work stacks on top.

Recommended phasing (§7) reflects this option.

---

## 6. Locked answers (m, 2026-05-06)

m signed off on all 19 recommendations verbatim. Implementation proceeds on these answers.

### Surface-by-surface aggregation policy (Q1–Q8, Q16–Q19)

| Q | Question | Recommendation |
|---|---|---|
| Q1 | `/projects/{id}` Fristen / Termine / Aktivität / Verlauf — aggregate descendants by default? Per-section toggle? | **Aggregate by default**, narrow toggle ("nur direkt") on each section. With attribution chip per row. |
| Q2 | Project tree sidebar count badges — direct, descendant, or both? | **Both**: render `(direct + descendants)` when both are non-zero. Single number when descendants is zero. |
| Q3 | Dashboard widgets — per-project subtree filter? | **No change v1.** Dashboard is the global home. Subtree filter is a plausible follow-up (paged off the existing `/dashboard?project_id=` query param). |
| Q4 | `/events` filtered by project — include descendants? | **Yes**, default. Already does. Toggle for narrow. |
| Q5 | `/deadlines` and `/appointments` lists — same? | **Yes**, same shape as `/events`. |
| Q6 | Search results across descendants? | **Subtree default when in project context**, global otherwise. Cmd-K opened from /projects/X searches X's subtree; opened from / searches global. |
| Q7 | CalDAV — direct memberships only, or path-reachable? | **No change.** CalDAV scope = visibility (path-reachable, current behaviour). Subtree aggregation would over-include. |
| Q8 | Email reminders — same scope? | **No change.** Same rule as CalDAV. |
| Q16 | Default direction when an answer isn't obvious — aggregate or narrow? | **Aggregate.** Safer "no missed information" outweighs noise risk. |
| Q17 | Path-walk query shape scales? | **Yes** for current sizes. Confirmed by t-paliad-124 test on live DB. No materialised counts in v1. |
| Q18 | Performance escape hatch? | **No bound v1.** Materialised view if a real bottleneck appears. Don't pre-optimise. |
| Q19 | Aggregation indicator per row? | **Yes.** Attribution chip in the row showing the descendant project ("auf: Case 14-vs-Müller"). |

### Effective-team semantics (Q9, Q11, Q13–Q15)

| Q | Question | Recommendation |
|---|---|---|
| Q9 | "Effective team of a Client" — display-time only, or grants visibility/authority somewhere new? | **Display-time aggregation by default**; visibility already covered by the existing path-walk + new derivation predicate; authority is opt-in (Q12). |
| Q11 | Derivation rule shape — pair-level or unit-level? Where is lawyer↔PA stored? | **Unit-level derivation.** Pair-level rejected (the data doesn't exist; the unit is the right granularity). Schema: new `partner_unit_members.unit_role` column + new `project_partner_units` junction. |
| Q11b (new) | Materialise derived rows into `project_teams`, or compute on read? | **Compute on read.** Extend `can_see_project()` with one EXISTS branch. No drift; sub-ms cost. |
| Q13 | Display annotation? | **Sectioned rendering on the Team tab** with explicit "Abgeleitet (Partner Unit)" subsection + per-row source label. Verlauf annotates approval rows from derived members. Inline badges on row context too. |
| Q14 | Cascading + hierarchy — does derived membership inherit visibility down? | **Yes** via the existing path-walk predicate. The derived membership behaves like any other team membership at the level it derives. |
| Q15 | Removal / churn — retroactive or going forward? | **Retroactive on current state** (computed-on-read drops the derivation immediately) but **historical record is immutable** (a past approval signed by Anna stays valid; she just can't sign new ones). |

### Approval interaction (Q10, Q12)

| Q | Question | Recommendation |
|---|---|---|
| Q10 | Does a Client policy inherit down to a Case? | **No automatic inheritance.** Per t-138 design. Add a "Eltern-Politik (zur Information)" panel on the child's policy page so the parent's rules are visible without applying. "Übernehmen" button stays the explicit copy mechanism. |
| Q12 | Does derivation carry approval authority? | **No by default**; opt-in per project via `derive_grants_authority` flag on the `project_partner_units` row. Strict default = visibility-only is consistent with paliad's deny-by-default safety stance. When opted in, derived members count as `peer` approvers with mapped role; `decision_kind` distinguishes `derived_peer` from regular `peer` for audit clarity. |

---

## 7. Implementation phasing

This is a meaty piece. Recommended split into three branches, three PRs:

### Phase 1 — Bug fix (read-path subtree compliance)

Smallest, highest value. Lands first regardless of how the rest plays out.

1. `DeadlineService.ListForProject` → remove + redirect callers to `EventService.ListVisibleForUser({ProjectID, Type: deadline})`. Or switch body to use `projectDescendantPredicate`.
2. `AppointmentService.ListForProject` → same.
3. `ProjectService.ListProjectEvents` → switch WHERE to `projectDescendantPredicate`. Optional `direct_only bool` arg.
4. `frontend/src/client/projects-detail.ts:216/262/271` → switch fetch to union endpoints.
5. Add attribution chip rendering when row's `project_id !== currentProjectID`.
6. Add the "Inkl. Unterprojekte / Nur direkt" toggle on each section. Default = subtree.
7. Update t-paliad-124 test to also exercise the per-project narrow path (so the bug-class can never re-emerge).

No schema. ~400 LoC. Single PR. **m's bug closes.**

### Phase 2 — Schema for partner-unit derivation

Migration 055 — adds `partner_unit_members.unit_role`, `project_partner_units`, `approval_role_from_unit_role`, extends `can_see_project()`. Backfill: every existing unit member defaults to `unit_role='attorney'` (no immediate derivation since `derive_unit_roles` defaults to `{pa, senior_pa}`).

Backend services: `DerivationService` (Attach, Detach, DerivedMembersForProject, EffectiveProjectRole). Wire into `TeamService.ListSubtreeMembers`. Update `partner_unit_service.go` for unit_role tagging.

Frontend: `/admin/partner-units/{id}` Mitglieder tab gets the unit_role select. `/projects/{id}/settings/team` Partner Units section. Project detail Team tab gains the new "Aus Unterprojekten" + "Abgeleitet (Partner Unit)" sections.

i18n keys, audit-event types, integration tests. Single PR, ~1500 LoC.

### Phase 3 — Approval extension (depends on Phase 2 and on t-138 landing)

Extends t-138's `canApprove` to use `EffectiveProjectRole`. Updates the inbox SQL with the derivation eligibility branch. Adds `derived_peer` to `decision_kind`. Verlauf rendering for "abgeleitet" approvals.

Single PR, ~400 LoC. Depends on t-138 (migration 054 + ApprovalService) being on main.

### Sequencing

```
       phase 1 (bug fix)
            ↓
       phase 2 (schema 055 + derivation service)
            ↓
   t-138 lands (cronus's PR 054)
            ↓
       phase 3 (approval extension)
```

Phases 1 and 2 are independent of t-138 and can land in either order. Phase 3 is gated.

### Implementation recommendation

**Phase 1**: any coder. Mechanical refactor, no design judgement needed once locked.

**Phase 2**: noether (this worktree) or a fresh coder. Schema + service + frontend; pattern-fluent.

**Phase 3**: cronus (already deep in t-138 internals) or noether. Coordination heavy.

Head decides splits. If a single coder takes all three, recommend phases 1 → 2 → 3 in sequence on a single rolling branch, with a self-merge of phase 1 first so the bug fix ships quickly.

---

## 8. Trade-offs and known limitations

### 8.1 Aggregate-by-default risks information overload

A Client with 200 deadlines across 100 Cases shows 200 rows on `/projects/{client_id}`. This is "right" by the no-missed-information rule but can be visually overwhelming. Mitigations in v1:

- Status filter chips (already present) narrow to "overdue / today / week / etc.".
- Attribution chip lets the user click into the descendant for focus.
- "Nur direkt" toggle is one click away.

If real-world use shows the firm wants different defaults per surface (e.g. Termine narrow by default but Fristen aggregate), the policy table in §2.2 is the clean place to flip individual cells. Not all-or-nothing.

### 8.2 Per-unit-role column re-introduces a rank concept

§3.2.2 adds `unit_role` to `partner_unit_members`. The codebase has been deliberate (t-paliad-051, t-paliad-138) about NOT having a firm-wide rank column. The design argues this is a **per-unit role** rather than a firm-wide one — the same person can be `lead` in one unit and `attorney` in another. In practice, paliad has ~5-10 partner units and most users belong to exactly one, so the per-unit distinction is a fig leaf for what's effectively a firm-wide tier in 95% of cases. m should be aware of this:

- If m is OK with the per-unit framing as honest enough, this design works.
- If m wants stricter adherence to no-rank-on-user, an alternative is to derive PA-ness from `project_teams.role` history ("if user has role='pa' on any project, they're a PA"). But that's circular — the whole point of derivation is to create memberships from out-of-band data.
- A third alternative: drop the role distinction entirely and derive ALL unit members onto the project. Wider derivation, simpler schema, but breaks m's "only PAs derive" intent.

Recommendation: per-unit role. Revisit if it grows pain.

### 8.3 Derived authority is a per-project flag — small UX cost

`project_partner_units.derive_grants_authority` is a boolean per (project, unit) row. To turn on PA approvals on a specific matter, the project lead has to attach the unit AND flip the flag. Two-step. The default is safe but the flow is one click longer than "derivation = full membership". Trade-off chosen for honesty and audit clarity.

### 8.4 No retroactive backfill of unit_role

Migration 055 sets every existing partner_unit_members row to `unit_role='attorney'` and walks away. Admins re-tier as needed. Active derivation only starts when both (a) units get re-tiered AND (b) units get attached to projects via the new junction. Day-1 deploy = zero behaviour change. Slow opt-in is intentional.

### 8.5 Computed-on-read visibility predicate adds query cost

§3.3 Option B extends `can_see_project()` with one EXISTS branch. Every project-scoped query pays this cost. Estimate: <1ms on the small tables (`project_partner_units` < 100 rows, `partner_unit_members` < 200 rows). Should be in the noise. If it isn't, fall back to Option A (materialised) — but only if real production telemetry says so.

### 8.6 Dashboard / CalDAV / email don't get subtree aggregation

Per §2.3, those surfaces stay as-is. A user looking at `/dashboard` doesn't see "all deadlines under any Client they're on". Reasonable for v1 (the dashboard is the user's personal home), but if m wants the dashboard to also offer a per-Client subtree filter, that's a small follow-up (one query param, one CTE branch).

### 8.7 Coordination cost with t-138

Phase 3 waits on t-138 landing. If t-138 takes weeks to merge for any reason, Phase 3 is parked. The bug fix (Phase 1) and the schema groundwork (Phase 2) are independent and can ship without t-138.

---

## 9. End-of-design checklist

- [x] Locked constraints summarised (§0)
- [x] Live state verified vs issue claims (§1)
- [x] Sub-design 1 — surface aggregation policy (§2)
- [x] Sub-design 2 — effective team + partner-unit derivation (§3)
- [x] Sub-design 3 — approval × hierarchy × derivation (§4)
- [x] Migration shape (§5)
- [x] 19 open questions with proposed answers (§6)
- [x] Implementation phasing (§7)
- [x] Trade-offs (§8)

**Inventor stays parked.** Design LOCKED 2026-05-06 — m signed off on all 19 §6 recommendations. Awaiting head's coder-shift assignment. No `/mai-coder` self-load.
