Files
paliad/docs/design-hierarchy-aggregation-2026-05-06.md
m 2247c0707d docs(t-paliad-139): design lock — m signed off on all 19 §6 recommendations
m's go/no-go pass at 2026-05-06 15:58: "I agree with all your recommendations
- go." All 19 questions in §6 lock as the recommended answers verbatim.

§0 status flipped from READY-FOR-REVIEW to LOCKED. New "Locked m decisions
on §6" subsection captures the highlights inline so future readers don't
have to scan the whole table to know what's pinned.

§13 end-of-design line updated to reflect the lock.

Implementation phasing (§7) unchanged:
- Phase 1: bug fix on the 3 narrow service methods (no schema, ~400 LoC,
  ships standalone, closes the user-visible /projects/{id} "Keine Fristen"
  bug).
- Phase 2: migration 055 (partner_unit_members.unit_role,
  project_partner_units, extended can_see_project()) + DerivationService +
  frontend Team-tab subsections + /admin/partner-units unit_role tagging
  + project /settings/team Partner Units section. Independent of t-138.
- Phase 3: approval extension — canApprove + inbox SQL widening for
  derived_peer decision_kind. Gates on cronus's t-138 (currently on
  mai/cronus/inventor-dual-control @ b3401ec) landing on main.

Inventor parked. Awaiting head's coder-shift assignment.
2026-05-06 15:59:37 +02:00

66 KiB
Raw Permalink Blame History

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

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

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

That route is internal/handlers/handlers.go:253handleListDeadlinesForProjectDeadlineService.ListForProject. The service body (deadline_service.go):

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

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:

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

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)

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)

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.

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:

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:

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. paattorney) → 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

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

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:

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:

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.

-- ============================================================================
-- 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 (Q1Q8, Q16Q19)

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, Q13Q15)

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

  • Locked constraints summarised (§0)
  • Live state verified vs issue claims (§1)
  • Sub-design 1 — surface aggregation policy (§2)
  • Sub-design 2 — effective team + partner-unit derivation (§3)
  • Sub-design 3 — approval × hierarchy × derivation (§4)
  • Migration shape (§5)
  • 19 open questions with proposed answers (§6)
  • Implementation phasing (§7)
  • 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.