Inventor design doc (kepler) for issue m/paliad#6. Splits the conflated
project_teams.role column into two axes:
- paliad.users.profession (firm-wide, drives t-138 approval ladder)
- paliad.project_teams.responsibility (per-project, lead/member/observer/external)
Approval ladder evaluated as tuple: profession_level if responsibility
opens the gate (lead/member), else 0. Policy grammar from t-138 stays
single-valued.
Verified live state: project_teams=3 rows (all 'lead'), partner_unit_members=20
rows (all default 'attorney'). Backfill is essentially trivial; risk is the
SQL rewiring (4 sites in approval_service.go, 2 in derivation_service.go,
2 in reminder_service.go) — all mechanical.
12 open questions from issue body answered with recommendations + rationale +
alternatives. Awaits m's go before any coder shift.
DESIGN READY FOR REVIEW.
Phase A2 of the data-display-model rethink. Builds on A1's API contract
(merged as cda4b40). User-visible.
What lands:
- TSX shells for /views (the view runner) and /views/new + /views/{slug}/edit
(the editor). One TSX per page; client/views.ts + views-editor.ts
hydrate.
- Three render-shape components in client/views/: shape-list.ts (table
for density=comfortable, compact one-line stream for density=compact —
the activity-feed look without a separate "activity" shape per Q4 lock-
in 2026-05-07), shape-cards.ts (day-grouped chronological), and
shape-calendar.ts (month grid with day-pills, mobile cards-fallback
notice on viewports <600px per design §9 trade-off 8).
- Generic view shell that resolves a slug to a system view (via
/api/views/system) or a user view (via /api/user-views), runs it via
POST /api/views/{slug}/run, dispatches to the matching shape, exposes
a 3-button shape switcher that swaps the live render without re-fetching,
and surfaces the inaccessible-projects toast when the substrate flags
some IDs (Q17 fail-open attribution).
- View editor with widgets for name/slug/icon, sources (4 checkboxes),
scope mode (all_visible / my_subtree / personal_only), time horizon
(six fixed options), shape, and list density. Slug regex enforced
client-side mirroring the server validator. Save → POST/PATCH; delete
→ simple yes/no confirm (Q25 lock-in).
- Sidebar "Meine Sichten" group between Arbeit and Werkzeuge. Renders
empty server-side; client/sidebar.ts.initUserViewsGroup() hydrates from
GET /api/user-views on mount, injecting one nav item per saved view
+ an always-present "+ Neue Sicht" trailing entry. show_count=true
views get a sidebar badge updated by a fire-and-forget run query.
- Page handlers /views (most-recently-used redirect or onboarding shell),
/views/{slug}, /views/new, /views/{slug}/edit. All gateOnboarded.
- 91 new i18n keys (DE+EN) covering nav.group.user_views, view shell,
shape labels, source/kind/horizon/scope vocabulary, editor form,
empty/error/onboarding states.
- ~250 lines of CSS for the views shell, list/cards/calendar shapes,
Meine Sichten sidebar group.
- build.ts registers views.tsx + views-editor.tsx page renderers and
the two client bundles.
Frontend builds clean (i18n codegen 1700→1791 keys), backend builds +
vets clean, all tests pass, IIFE wrap intact on the new bundles.
m's lock-in 2026-05-07: agree with all recommendations on Q1-Q18 and §10
Q19-Q27, with one correction on Q4: "activity" is a content selection
(sources + filters), not a render shape. Folded into `list` shape with
density: "compact" + actor/time columns. Shape ⊥ source — any source can
render in any shape.
Render shapes for v1: list / cards / calendar (3, was 4).
PR split decision (delegated to inventor): A1 backend substrate + API
(no UI change, ~1800 LoC, smoke via curl) → main → A2 frontend Custom
Views UI (~1600 LoC, additive on A1) → main.
Status flipped DRAFT → LOCKED. Inventor → coder transition initiated.
Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":
1. **All derived members labeled 'Attorney'.** Migration 055 added
partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
the column in the admin UI. So 100% of pum rows are 'attorney' and
Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
surfaces every member as 'attorney' even when they're really PAs.
2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
wins, every other unit-membership dropped. Judith Molarinho Vaz +
Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.
**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
bool_or(derive_grants_authority), GROUP BY user. One row per user, every
(unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
directly into the Go struct. Pinned by unit test.
**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
(unit, role) source — single-unit users render as before
("über: **Lehment** [Sicht]"); multi-unit users render
"über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.
**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
already shipped in t-paliad-139 Phase 2). Disables during request,
rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.
**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).
**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).
After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
The FOUC script in PWAHead.tsx sets `<html class="sidebar-pinned">`
pre-paint, which kept body padding correct from frame 1 — but the
.sidebar element's own width keyed off `.sidebar.pinned` (set by
initSidebar in DOMContentLoaded). That made every navigation paint
the rail at collapsed width, then animate width 150ms → pinned width
once JS ran. Visible slide-in on Dashboard / Agenda / Projekte / etc.
Fix: extend every `.sidebar.pinned` rule with a parallel
`:root.sidebar-pinned .sidebar ...` selector so the html-class set
pre-paint is sufficient to render the full pinned visual state from
frame 1 (width, label opacity, pin/resize/badge visibility, search
input). Runtime initSidebar still mirrors `.pinned` onto the element
itself for explicit pin/unpin click animation. Same dual-selector
pattern already used by `.has-sidebar.sidebar-pinned` /
`:root.sidebar-pinned .has-sidebar` for body padding.
Mobile unaffected — FOUC script only sets html.sidebar-pinned when
window.innerWidth >= 1024, and initSidebar clears it on resize.
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.
Schema (migration 055 update)
-----------------------------
- paliad.approval_requests.decision_kind CHECK extended to accept
'derived_peer'. Down migration restores the t-138 two-value CHECK.
Live SQL dry-run confirmed the new value is accepted.
Service layer
-------------
- approval_levels.go: new constant DecisionKindDerivedPeer.
- approval_service.go (4 sites widened with the derivation EXISTS branch):
1. canApprove — third resolution step after global_admin + direct/
ancestor team membership: matches partner-unit-derived members
on path with derive_grants_authority=true and a unit_role whose
approval_role_from_unit_role mapping meets the threshold.
Returns DecisionKindDerivedPeer when this branch is the one that
passed.
2. hasQualifiedApprover (the deadlock-check at submit time) —
widened so a project with no direct approvers but an authority-
granting unit attachment is still submittable.
3. ListPendingForApprover (the /inbox query) — third UNION ALL
branch so derived authority sees their queue.
4. PendingCountForUser (the bell-badge query) — same widening so
derived authority sees the count tick.
All four queries reuse paliad.approval_role_from_unit_role(text) added
by Phase 2 of migration 055.
Frontend
--------
- 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
"Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
derived member (Partner Unit)". Verlauf rendering of the third
decision_kind value works through the existing translateEvent /
decision_kind switch with no other change. 1606 keys total.
Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.
Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):
- paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
distinction so derivation can target specific tiers without re-
introducing a firm-wide rank column. The same human can be 'attorney'
in one unit and 'lead' in another.
- paliad.project_partner_units junction (project_id, partner_unit_id,
derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
DEFAULT false, attached_at, attached_by) with composite PK and RLS
(read = can_see_project; write = global_admin OR project lead).
- paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
derived authority is consulted by the t-138 ladder.
- paliad.can_see_project extended with one EXISTS branch — derivation
walks the path: a user is visible on P if any (ancestor of P) is
attached to a unit they are a member of with a matching unit_role.
No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.
Backend services
----------------
- DerivationService (new, internal/services/derivation_service.go):
AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
ListDerivedMembers (path-walking dedupe by closest attachment),
ListDescendantStaffed (descendant-direct rows excluding ancestor-
already-staffed), EffectiveProjectRole (returns role + source ∈
{direct, ancestor, derived} for the t-138 approval gate in Phase 3).
- PartnerUnitService extensions:
PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
role read in tx, audit emit 'member_role_changed'. ListMembers and
ListWithMembers SELECT projection now includes pum.unit_role.
Handlers
--------
- GET /api/projects/{id}/partner-units → ListAttachedUnits
- POST /api/projects/{id}/partner-units → AttachUnitToProject
- DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
- GET /api/projects/{id}/team/derived → ListDerivedMembers
- GET /api/projects/{id}/team/from-descendants → ListDescendantStaffed
- PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
- Services bundle gains Derivation; cmd/server/main.go wires it.
Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
- "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
- "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
Augen] badge per the m-locked honesty rule (§3.5).
- "Partner Units" — attached-unit list with attach/detach controls
(lead/admin only) and a form picker for derive_unit_roles +
derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).
Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.
i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).
CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.
Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
m's bug: /projects/{client_id} renders "Keine Fristen" / "Keine Termine" /
"Noch keine Ereignisse" even when descendant Cases carry deadlines, appts,
and audit events. Live verification on Siemens AG client
(61e3fb9e-29fb-44aa-867e-a89469e2cacb): 9 descendant projects, 19
deadlines, 37 project_events, 4 appointments — none on the Client row,
all invisible until now.
Root cause: 3 legacy per-project read paths used WHERE project_id = $1
(exact match), bypassing the projectDescendantPredicate primitive that
internal/services/visibility.go:68 already provides and that the t-124
union endpoints (DeadlineService.ListVisibleForUser etc.) already use.
Backend
-------
- DeadlineService.ListForProject(..., directOnly bool): subtree by
default via WHERE project_id IN (SELECT pp.id FROM paliad.projects pp
WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[])); collapses to
WHERE project_id = $1 when directOnly=true.
- AppointmentService.ListForProject: same shape.
- ProjectService.ListEvents(..., directOnly bool): same shape, plus
LEFT JOIN paliad.projects to surface project_title for the Verlauf
attribution chip on /projects/{id}. Inner subquery aliased pp to
avoid shadowing the outer join's p.
- models.ProjectEvent: new optional ProjectTitle string for the Verlauf
enrichment. Other readers leave it nil and the JSON serialiser omits
it (json:"project_title,omitempty").
- handlers/{deadlines,appointments,projects}.go: handler reads
?direct_only=true|false and passes through to the service. New
handlers.parseDirectOnly helper centralises the parse.
- project_filter_descendants_test.go: extended to also pin
DeadlineService.ListForProject + AppointmentService.ListForProject
+ ProjectService.ListEvents (live-DB test, skipped without
TEST_DATABASE_URL).
Frontend
--------
- projects-detail.ts: switched the deadline + appointment fetches from
/api/projects/{id}/deadlines + /appointments (legacy narrow) to
/api/events?type=deadline|appointment&project_id={id} (the union
endpoints, already aggregating + enriching with project_title). The
Verlauf still uses /api/projects/{id}/events but with the new
direct_only flag wiring.
- New subtreeMode state machine + URL param ?subtree=false. Default =
subtree (true). persistSubtreeMode replaceState keeps back-button
friendly.
- 3 new .subtree-toggle buttons in /projects/{id} History, Deadlines,
Appointments sections. Shared state across the three; clicking any
toggle reloads all three sections at once.
- attributionChip(rowProjectID, rowProjectTitle): inline chip "auf:
Case 14-vs-Müller" rendered when row.project_id !== currentProjectID.
Suppressed for direct rows.
- Deadline / Appointment / ProjectEvent interfaces gained an optional
project_title for the chip data path.
- 3 new i18n keys: aggregation.toggle.subtree (Inkl. Unterprojekte /
Incl. sub-projects), aggregation.toggle.direct_only (Nur direkt /
Direct only), aggregation.attribution.on (auf / on). DE+EN.
- global.css: .subtree-toggle, .subtree-toggle--active,
.aggregation-chip — small additive styling.
No schema. No migration. Phases 2 + 3 stack on top per design §7.
Root cause: `.collab-suggestions` had `display: none` in CSS but no JS site
ever toggled it back on. Suggestions rendered into a permanently hidden div.
Bug originated when the akten-collab-* pattern was renamed and copied for
project team-add and partner-units member-add — the original akten-neu.ts
toggled `style.display`, but the copies relied on innerHTML alone.
Fix: switch to content-driven visibility — `.collab-suggestions:not(:empty)
{ display: block }`. No JS changes needed at consumer sites; fixes all three
broken pickers (project team-add, project parent picker, partner-units member-
add) at once. Added missing styling for `.collab-suggestion` items (padding,
hover, separators) — they were unstyled even when visible.
Plus: invite-new-user inline affordance on project /team. When the typed
query matches zero existing users, a "Benutzer nicht gefunden? Einladen"
row appears below the dropdown. Click opens the existing global invite modal
(sidebar-invite-btn → /api/invite) and pre-fills the email if the query
looks like one. No new backend, no new modal — reuses what /admin/team and
the sidebar already use.
Commit 8 of 8. Bilingual (DE primary / EN secondary) translations for
the four approval event_types per entity that ApprovalService emits
into paliad.project_events:
deadline_approval_requested / _approved / _rejected / _revoked
appointment_approval_requested / _approved / _rejected / _revoked
Each gets:
- event.title.<event_type> — full Verlauf-card heading
- event.description.<event_type> — full-sentence localized description
- dashboard.action.short.<event_type> — verb-form for the dashboard activity feed
The existing translateEvent dispatch in i18n.ts handles these
automatically — it already keys off event.title.<event_type> for the
title, and the deadline_* / appointment_* prefix branch in
translateEventDescription falls through to event.description.<event_type>
when the stored body has no quoted title (which is true for the
approval-event descriptions emitted by ApprovalService).
Result: every project's Verlauf tab now renders the full 4-eye
lifecycle trail inline alongside the existing deadline_created /
deadline_updated / etc. rows. The /admin/audit-log timeline picks
them up too via the union path.
Pair-card rendering (request + decision side-by-side keyed by
metadata.approval_request_id) was a stretch goal in the design doc;
the current per-event row rendering already conveys the full story
chronologically without needing that pairing logic.
Commit 7 of 8. Outbound surfaces honour the pending-approval state
instead of going silent on it.
CalDAV (caldav_ical.go formatAppointment): when an appointment is
approval_status='pending', the iCal SUMMARY line is prefixed with
"[PENDING] ". External clients (Outlook, Apple Calendar, etc.) thus
display the unverified state honestly. Approved entries sync clean.
Email reminder digest (reminder_service.go):
- digestRow gains ApprovalStatus, sourced from f.approval_status in
the SELECT.
- Each pending row's Title is rewritten to "[PENDING] <title>" before
it lands in the template — visible in every email-rendered list.
- Template data carries PendingCount (count of pending rows in this
digest) + InboxURL so future template revisions can render a
banner like "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
/inbox" without further code changes. Existing templates unchanged
for backwards compat; the prefix on row titles already conveys the
signal.
- IsPending flag on each item map for future per-row template
conditionals.
Rationale: silence on a pending change is the worst outcome for a
4-eye system. The user's external calendar and reminder mail must
reflect "this exists but isn't verified" so they can act before the
deadline lapses.
Commit 6 of 8. Renders the approval-pending warning pill on the two
busiest list surfaces:
- /events (deadline + appointment list): ⚠ pill next to the title +
soft-tinted row via .entity-row--pending-update modifier.
- /agenda (timeline): ⚠ pill in the headline + same row tint.
Changes:
- internal/services/event_service.go: EventListItem gains
ApprovalStatus *string; projectDeadline / projectAppointment
populate it from the embedded model.
- internal/services/deadline_service.go ListVisibleForUser: SQL adds
f.approval_status / pending_request_id / approved_by / approved_at
to the SELECT so DeadlineWithProject hydrates them.
- internal/services/appointment_service.go ListVisibleForUser: same
for appointments + completed_at.
- internal/services/agenda_service.go: AgendaItem gains
ApprovalStatus; the per-source SQL queries select it; the
loadDeadlines / loadAppointments projection sets it.
- frontend/src/client/events.ts renderRow: adds entity-row--pending-update
modifier and an inline approval-pill on the title cell when status='pending'.
- frontend/src/client/agenda.ts renderItem: same treatment on the
agenda-item headline.
Generic "pending update" label (approvals.pending_update.label) — not
lifecycle-specific. The inbox carries the lifecycle detail. Showing
just one pill keeps the visual signal clear; an approver scanning a
list of pending entities sees them at a glance via the row tint, then
clicks through to /inbox to see what's pending and act.
Detail pages (/deadlines/{id}, /appointments/{id}) and /dashboard
deadline rail — pill rendering for those surfaces deferred to a
follow-up to keep this commit focused. Rendered everywhere it
matters most for daily use.
Commit 5 of 8. End-user surface for the approval workflow:
- /inbox page (frontend/src/inbox.tsx + client/inbox.ts) with two tabs:
"Zur Genehmigung" (requests I qualify to approve) and "Meine
Anfragen" (requests I submitted). Each row shows the project, entity
title, lifecycle event, requester name + age, the date-field diff
(for update/complete/delete) and the relevant action buttons:
approve + reject when on pending-mine, revoke when on mine.
Historic rows render a status pill instead of buttons.
- Sidebar bell entry "Genehmigungen" (with sidebar-inbox-badge) under
the Übersicht group. sidebar.ts polls /api/inbox/count every 60s and
shows the count (or 9+ ceiling) when > 0.
- Server registration: GET /inbox → dist/inbox.html, gated by
gateOnboarded. Already-registered API endpoints (commit 4) handle
the data path.
- Bilingual (DE primary / EN secondary) i18n strings under
approvals.* — labels, status names, lifecycle names, role names,
decision-kind names, action verbs, error messages. ~50 new keys.
- Pending-state CSS classes: .approval-pill, .approval-pill--historic,
.entity-row--pending-{create,update,complete,delete},
#sidebar-inbox-badge. Soft-tint rows + amber pill so an approver
can scan a list of pending entities at a glance. Used by commit 6
(pending pills across surfaces) — no other surface picks them up
yet, but the styles are wired and ready.
- Sidebar.tsx navItem signature gains an optional badgeID parameter
so any future sidebar entry can host a count-badge with one extra
argument (no per-entry custom rendering).
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.
Edit mode now exposes a project picker so a deadline or appointment can be
moved to a different matter. Backend Update accepts project_id (and
clear_project for appointments), validates visibility on the destination,
and emits *_project_changed audit rows on both the OLD and NEW project so
each side's Verlauf still shows the move.
Personal-to-project linking and project-to-personal unlinking are gated by
the existing personal-Appointment creator check; project-to-project moves
re-use the existing requireMutationRole gate plus a fresh visibility check
on the target.
Three coordinated sub-designs in one doc, scoped to m's locked constraints
(2026-05-06):
1. Surface-by-surface aggregation policy. Bug surface fix:
/projects/{client_id} renders "Keine Fristen" because
DeadlineService.ListForProject + AppointmentService.ListForProject +
ProjectService.ListProjectEvents all WHERE project_id=$1 exact-match
instead of walking paliad.projects.path descendants. The shipped t-124
contract (projectDescendantPredicate, deadline_service.go:133 etc.)
already aggregates correctly on the union endpoints — three legacy
narrow paths just bypass it. Per-surface decision table for events /
deadlines / termine / Verlauf / project tree counts / dashboard /
CalDAV / email / search.
2. Effective-team semantics. Three structural gaps in the issue's
premise (verified against schema):
- No project↔unit junction (partner_unit involvement on a project).
- No PA/lawyer distinction in partner_unit_members (no role column).
- No lawyer↔PA pairing anywhere — Q11's "where is it stored" → nowhere.
Proposes:
- paliad.partner_unit_members.unit_role (lead|attorney|senior_pa|pa|paralegal),
unit-scoped not firm-wide so 3-axis principle holds.
- paliad.project_partner_units junction with derive_unit_roles[]
(default {pa, senior_pa}) + derive_grants_authority bool.
- Compute-on-read derivation via extended can_see_project() — no
materialised state, no drift.
- Display-effective vs visibility-effective team are different sets;
rename ListEffectiveMembers to ListVisibilityEffectiveMembers + add
ListSubtreeMembers.
3. Approval policy × hierarchy × derivation. Coordinates with t-138
(cronus, mai/cronus/inventor-dual-control @ 7d1ddb9):
- Q10: keep cronus's no-auto-inheritance, harden UX with a "Eltern-
Politik (zur Information)" panel showing parent rules without
applying.
- Q12: derived members visibility-only by default; per-(project, unit)
opt-in flag derive_grants_authority. When opted in, decision_kind
extends with derived_peer for honest audit chronology.
- canApprove + inbox SQL extension shape spec'd; coordinates with
cronus's t-138 §3.4 / §7.4.
Locked m decisions surfaced in §0:
- Behaviour is surface-specific.
- Effective Team of a Client = direct ∪ descendants ∪ partner-unit-derived.
- PA derivation = unit-on-project trigger.
- Derivation honesty: annotated everywhere.
- paliad-only scope.
19 design questions with proposed answers in §6 for m to lock. Migration
055 specced (§5). Implementation phased into 3 PRs (§7) — Phase 1 bug fix
ships standalone if m wants quick win.
Inventor parked. Awaiting m go/no-go before coder shift.
Combined backend API for the upcoming policy-authoring page (commit 4)
and inbox + bell (commit 5). Registers:
Policy CRUD (admin-only via RequireAdminFunc gate):
- GET /api/projects/{id}/approval-policies
- PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
- DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
Inbox (any authenticated user; service-layer query gates by visibility
+ role-tier match):
- GET /api/inbox/pending-mine — requests I qualify to approve
- GET /api/inbox/mine — requests I submitted
- GET /api/inbox/count — bell badge count
- GET /api/approval-requests/{id} — one hydrated request
Decisions (caller authorization checked at service layer; the CHECK
constraint on approval_requests blocks self-approval as a second
defence):
- POST /api/approval-requests/{id}/approve
- POST /api/approval-requests/{id}/reject
- POST /api/approval-requests/{id}/revoke
Error mapping (writeApprovalError):
- ErrSelfApproval → 403 self_approval_blocked
- ErrNoQualifiedApprover → 409 no_qualified_approver
- ErrConcurrentPending → 409 concurrent_pending
- ErrNotApprover → 403 not_authorized
- ErrRequestNotPending → 409 request_not_pending
Frontend pages (the policy authoring tab on /projects/{id}/settings
and the /inbox page with bell) follow in subsequent commits — the
endpoints are usable via curl + admin tooling immediately.
Commit 3 of 8. The 4-eye gate now actually fires. With migration 054
applied and an approval_policies row configured for a project, the
relevant Create/Update/Complete/Delete on a Deadline or Appointment
flips approval_status='pending' and emits a *_approval_requested audit
event. Without policies, behaviour is unchanged.
Backend changes:
- models.Deadline + models.Appointment gain approval_status,
pending_request_id, approved_by, approved_at; appointments also gain
completed_at (for the appointment:complete lifecycle event).
- deadlineColumns + appointmentColumns include the new fields so
every existing read path hydrates them via sqlx StructScan with
no per-call-site changes.
- DeadlineService gains SetApprovalService (nil-tolerant). Wired in
main.go after the bundle is built.
- AppointmentService gains the same hook + dependency.
Lifecycle wiring:
- DeadlineService.Create / Update / Complete / Delete each consult
the approval gate. Update only triggers approval when a date-bearing
field actually changes (Q4 allowlist: due_date, original_due_date,
warning_date). Cosmetic edits (title, description, notes,
rule_code, event_type_ids, status, completed_at via reopen) bypass.
- AppointmentService.Create / Update / Delete same shape. Update
only gates on start_at / end_at changes. Personal appointments
(project_id IS NULL) never gate (no project policy to consult).
- Delete is the one stage-then-write exception: the row stays alive
with approval_status='pending' until the approver hard-deletes
(approve) or restores it (reject). On no-policy projects, delete
is immediate as before.
- Concurrent-pending guard: any mutation on a row whose
approval_status='pending' returns ErrConcurrentPending. The user
must wait for the in-flight request to settle (or revoke if
they're the requester).
Pre_image capture: the date-bearing fields that are about to change
are snapshotted into the approval_requests.pre_image jsonb at submit
time. Reject/Revoke applies them back over the row to revert.
Commit 2 of 8 — the workflow engine for the 4-Augen-Prüfung. Wires the
service into the handlers.Services bundle so commit 3 can call into
SubmitCreate/Update/Complete/Delete from DeadlineService and
AppointmentService.
Public surface:
- Submit{Create,Update,Complete,Delete} — invoked by Deadline /
AppointmentService inside their existing tx. Looks up policy,
runs the deadlock check, inserts paliad.approval_requests, marks
the entity pending, emits the *_approval_requested project_events
audit row.
- Approve / Reject / Revoke — top-level operations (own tx). Approve
finalises the lifecycle (clears pending markers + sets approved_by
for non-delete; hard-deletes for delete). Reject / Revoke revert
the entity from pre_image (delete a pending-create, restore date
fields, NULL completed_at).
- ListPendingForApprover / ListSubmittedByUser / GetRequest /
PendingCountForUser — read paths the inbox + bell will hit in
commit 5.
- ListPolicies / UpsertPolicy / DeletePolicy — CRUD for the
authoring page in commit 4.
Self-approval is blocked at three layers:
1. canApprove() returns ErrSelfApproval when caller == requester.
2. The DB CHECK constraint approval_requests_no_self_approval.
3. The deadlock check excludes the requester from the pool.
Strict-ladder helper levelOf(role) mirrors the SQL function added in
migration 054. Path-walk authorization: ancestors with eligible roles
qualify for descendant requests (matches the visibility predicate).
Tests:
- Pure-Go: levelOf strict-ladder semantics, IsValidRequiredRole,
approvalEventType. All pass under `go test`.
- Live-DB (TEST_DATABASE_URL): no-policy noop; submit→approve cycle;
reject-create deletes; reject-update restores pre_image;
no-qualified-approver fail; revoke flow; policy CRUD roundtrip.
Skipped when TEST_DATABASE_URL is unset, mirroring the existing
audit_service_test pattern.
No call sites in DeadlineService / AppointmentService yet — that's
commit 3. Paliad continues to behave identically until that lands.
Schema-only commit (1 of 8) for the 4-Augen-Prüfung workflow per
docs/design-approvals-2026-05-06.md. No Go code reads these yet —
paliad behaves identically until commit 2 wires ApprovalService into
the mutation paths.
Migration 054 adds:
1. `senior_pa` to paliad.project_teams.role CHECK. Drops both the
English `project_teams_role_check` and the German-legacy
`projekt_teams_role_check` (live-DB constraint name carried over
from migration 018's pre-rename era).
2. `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
ladder helper: lead(5) > of_counsel(4) > associate(3) > senior_pa(2)
> pa(1) > [local_counsel/expert/observer = 0 = ineligible]. Mirrors
the upcoming Go `levelOf()`.
3. `paliad.approval_policies` (project_id, entity_type, lifecycle_event,
required_role) — UNIQUE composite key gives at most 8 rows per
project. RLS: SELECT via can_see_project; INSERT/UPDATE/DELETE only
for global_admin (defence-in-depth; service-role pool bypasses RLS,
so the actual gate is service-layer).
4. `paliad.approval_requests` — operational pending workflow.
pre_image jsonb captures revert state; payload echoes the diff;
required_role snapshots the policy at request time. CHECK
`decided_by != requested_by` is the second layer of self-approval
block. RLS = same can_see_project predicate as deadlines /
appointments — anyone with project visibility sees pending requests.
5. `approval_status` (default 'approved'), `pending_request_id`,
`approved_by`, `approved_at` columns on both deadlines and
appointments. `appointments.completed_at` (new) lands the
appointment:complete lifecycle event.
6. Backfill: every existing deadline + appointment row marked
approval_status='legacy'. Per Q11, no retroactive approval; the
next mutation on a legacy row that hits an active policy follows
the normal flow.
Live-DB dry-run verified end-to-end: 20 deadlines + 5 appointments
backfill, both new tables instantiate cleanly, helper function returns
correct levels, self-approval CHECK fires on invalid INSERT, valid
pending insert succeeds.
Locked design for 4-Augen-Prüfung on Fristen + Termine. m-confirmed
decisions on all 11 open questions:
- Qualification gate reuses paliad.project_teams.role per-project
(no new firm-wide axis). Adds new value `senior_pa` to the enum.
- Strict ladder: lead > of_counsel > associate > senior_pa > pa.
Default required_role = associate. Per-project override allows pa-
approves-pa or senior_pa-tier escalation.
- Per-(project, entity_type, lifecycle_event) policy grammar — up to
8 settable rows per project in paliad.approval_policies.
- Edit-trigger allowlist = date-bearing fields only (Frist due_date /
original_due_date / warning_date; Termin start_at / end_at).
- Write-then-approve: row mutates immediately, approval_status flips
between approved/pending/legacy. Delete is the one stage-then-write
exception (hard-delete on approve, restore on reject).
- Refuse + global_admin override on single-qualified-approver deadlock.
- Pending state visualised everywhere — list views, agenda, dashboard
traffic-light, project detail, CalDAV-synced calendars (`[PENDING] `
title prefix), email reminders.
- Bell + /inbox page with two tabs (zur Genehmigung / meine Anfragen).
- Operational paliad.approval_requests + audit lifecycle written to
existing paliad.project_events (4 new event_types per entity).
- RLS = same can_see_project predicate; service layer enforces the
approve/reject action gate. CHECK constraint blocks self-approval.
- Mark-legacy backfill: approval_status='legacy' on existing rows;
next mutation flows through the gate.
Implementation phasing: single migration 054 + 8-commit PR plan
covering schema, service, wiring, policy authoring page, inbox,
pending pills, CalDAV/email integration, Verlauf rendering.
Inventor parked. Awaiting m go/no-go before any coder shift.
GET /api/tools/courts[?courtType=UPC-LD] returns the deadline-
computation slice of paliad.courts (id, code, names, country, regime,
court_type) — distinct from the rich Gerichtsverzeichnis at
/api/courts. Optional courtType filter narrows to a single tier.
POST /api/tools/fristenrechner and POST /api/tools/fristenrechner/
calculate-rule both accept an optional courtId field. When set, the
calculator resolves the court's (country, regime) and uses that
calendar; when omitted, the proceeding's existing jurisdiction column
seeds a sensible default — preserves today's behaviour for callers
that don't yet send a court.
Frontend: court-picker-row added to step 2 of the Fristenrechner
wizard. Visible only for proceeding types with multiple compatible
courts (today: every UPC-flavoured proceeding — UPC LDs span 12
countries, plus UPC CD seats and the CoA). DE-only proceedings (BPatG
nullity, BGH appeals, DPMA, EPA, EP grant) keep the form unchanged.
Picker re-runs the calc on selection so the user sees the same
deadlines shift to a different calendar without a manual click. i18n
key deadlines.court.label added for both DE and EN.
Default courts wired sensibly: UPC_INF / UPC_REV / UPC_PI etc. → UPC
LD München (HLC's home venue); UPC_APP / UPC_APP_ORDERS /
UPC_COST_APPEAL → UPC CoA Luxembourg; UPC_REV → UPC CD Paris.
Holiday struct gains Country (ISO-3166) + Regime ('UPC' | 'EPO' | "")
fields. AppliesTo(country, regime) is the matching rule the new lookup
methods filter through: a row matches when its Country equals the
court's country OR its Regime equals the court's regime. UPC LD München
(DE+UPC) sees DE federal + UPC vacations; LG München (DE+"") sees only
DE federal; UPC LD Paris (FR+UPC) sees FR + UPC. germanFederalHolidays
fallback now country-tagged 'DE' so the per-country filter applies it
only to DE-jurisdictional callers.
Public service methods (IsHoliday, IsNonWorkingDay, AdjustForNonWorking
Days, AdjustForNonWorkingDaysWithReason, findVacationBlock) all take
(country, regime). Cache stays year-keyed — single DB hit per year, all
courts touching that year share it.
New CourtService loads paliad.courts once + answers Lookup(id),
CountryRegime(id, defaultCountry, defaultRegime), All(), ByCourtType(t).
FristenrechnerService.CalcOptions / CalcRuleParams gain CourtID;
EventDeadlineService.Calculate gains courtID. When courtID is empty,
DefaultsForJurisdiction maps the proceeding's existing jurisdiction
column to a sensible (country, regime) default — UPC proceedings get
(DE, UPC), everything else gets DE-only — preserving today's behaviour
for callers that don't yet send a court.
Tests: new TestAppliesTo_CountryRegimeFilter + TestAppliesTo_Rules
cover the cross-product of (DE court / UPC LD München / UPC LD Paris /
LG München) × (DE federal / UPC vacation / FR holiday). Existing tests
threaded through with ('DE', 'UPC') to preserve behaviour they were
written to lock.
Adds paliad.countries (13 ISO-3166 codes), paliad.courts (41 entries
seeded from internal/handlers/courts.go), and the country/regime split
on paliad.holidays. The 33 t-paliad-121 UPC vacation rows previously
stored as country='UPC' migrate cleanly to country=NULL + regime='UPC'
— 'UPC' is a supranational regime, not an ISO country, and the new
shape lets a UPC LD München (country='DE', regime='UPC') pull both DE
federal holidays and UPC vacation entries while a UPC LD Paris
(country='FR', regime='UPC') pulls FR + UPC. Holidays now FK-protected
against typo'd country codes.
Archives m's locked design call (2026-05-05 18:51) plus live-codebase
verification: paliad.holidays.country exists per-country; paliad.courts
does not (must create); proceeding_types.jurisdiction is regime not
country (do not remove); 41 hand-curated courts already in
internal/handlers/courts.go ready to seed; HolidayService.loadYear is
country-blind today (latent bug); germanFederalHolidays merge is
hardcoded (must become country-conditional). Task stays ON-HOLD until a
non-DE forum or EPO closure-day calendar comes into scope.