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.
66 KiB
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):
- Surface-by-surface aggregation policy — a per-view decision table for whether a project filter narrows to the row's own
project_idor 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. - 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).
- 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_unitsjunction. - 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_authorityflag (Q12) —decision_kindextends withderived_peerfor 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) — whenfilter.ProjectIDis 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:253 → handleListDeadlinesForProject → DeadlineService.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:
DeadlineService.ListForProjectAppointmentService.ListForProjectProjectService.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:
- A way to mark a partner-unit as involved on a project (some
project_partner_units(project_id, partner_unit_id, …)junction). - 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).
- 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:
DeadlineService.ListForProject→ either remove it (callers redirect toEventService.ListVisibleForUser) or switch its body to useprojectDescendantPredicateand add adirect_only boolarg. Recommend: remove + redirect callers (1 site:handleListDeadlinesForProject). Same forAppointmentService.ListForProject.ProjectService.ListProjectEvents→ switch the WHERE clause fromproject_id = $1to the descendant predicate. Add adirect_onlyarg 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.- 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 toListVisibilityEffectiveMembersso 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 inteam_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:
- Direkt — direct memberships on this project.
- Geerbt aus Eltern-Projekten — ancestor-inherited (today's "Inherited" rows). Only visible on non-root projects.
- Aus Unterprojekten — descendant-inherited (NEW). Only visible on projects that have descendants. Each row carries the descendant project title in the "Quelle" column.
- 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) andpaliad.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:
- 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.
- 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.
- 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 intoproject_teamsfor 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 inderive_unit_roles.
Pros:
can_see_projectworks unchanged (derived rows are realproject_teamsrows).- 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 aspartner_unit_members_user_idx); pluspaliad.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
ListSubtreeMembersdoes — 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_unitsderivations.
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_rolechanges (e.g.pa→attorney) → ifattorneyis not inderive_unit_rolesfor 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: justWHERE project_id IN descendants(P.id)).derived_members(P)= users from any unit attached to P whoseunit_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:
- Switch
projects-detail.tsfetches to the union endpoints. - Update
ProjectService.ListProjectEventsto useprojectDescendantPredicate. - Optionally add the
direct_onlytoggle.
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.
DeadlineService.ListForProject→ remove + redirect callers toEventService.ListVisibleForUser({ProjectID, Type: deadline}). Or switch body to useprojectDescendantPredicate.AppointmentService.ListForProject→ same.ProjectService.ListProjectEvents→ switch WHERE toprojectDescendantPredicate. Optionaldirect_only boolarg.frontend/src/client/projects-detail.ts:216/262/271→ switch fetch to union endpoints.- Add attribution chip rendering when row's
project_id !== currentProjectID. - Add the "Inkl. Unterprojekte / Nur direkt" toggle on each section. Default = subtree.
- 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.rolehistory ("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.