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.
52 KiB
Design — Dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments
Author: cronus (inventor)
Date: 2026-05-06
Task: t-paliad-138 (Gitea m/paliad#3)
Branch: mai/cronus/inventor-dual-control
Status: DESIGN READY FOR REVIEW. Awaiting m go/no-go before any coder shift.
0. TL;DR
Add a 4-eye principle to paliad.deadlines and paliad.appointments. Every state-changing action (create / update-of-date-fields / complete / delete) submitted by one team member must be signed off by a qualified second team member from the same project before the change is "approved".
Six locked design decisions from m (2026-05-06):
| # | Question | Locked answer |
|---|---|---|
| Q1 | Where does the qualification level live? | Reuse project_teams.role per-project (no new firm-wide column). New value senior_pa added to the role enum. |
| Q1+ | Strict-ladder default? | Default approval-eligible = {lead, associate}. Per-project / per-event setting can extend to senior_pa or pa (so PAs can approve other PAs in some projects). |
| Q2 | Hierarchy semantics | Strict ladder. Higher level always satisfies lower. |
| Q3 | Policy granularity | Per-(project, entity_type, lifecycle_event) \— up to 8 settable rows per project. |
| Q4 | Edit-trigger fields | Only date-changing fields. Deadline: due_date, original_due_date, warning_date. Appointment: start_at, end_at. All other field changes bypass approval. |
| Q5 | Pending-state architecture | Write-then-approve. Field changes apply immediately; the entity carries approval_status='pending' until an approver flips it. (Delete is the one exception — see §5.4.) |
| Q6 | Inbox surface | Bell icon (sidebar header) + dedicated /inbox page with two tabs: "Zur Genehmigung" / "Meine Anfragen". |
| Q7 | Revocation | Pending-only revoke. After approval, only path back is a new request. |
| Q8 | Single-qualified-approver deadlock | Refuse + global_admin override. UI refuses with "Kein qualifizierter Approver verfügbar"; global_admin can manually approve as override (audit-marked). |
| Q9 | Audit / chronology | Both \— operational paliad.approval_requests table + new event types in paliad.project_events. Both creator and approver names persist on the entity row. |
| Q10 | RLS | Visible to project team, action gated by service. Same can_see_project() predicate; service layer checks "caller has required role tier AND caller_id != requested_by". |
| Q11 | Migration of existing rows | Mark legacy + skip backfill. All existing rows get approval_status='legacy'. New lifecycle events on legacy rows trigger normal approval flow. |
Plus m's explicit interjection: pending state must be visualised everywhere the entity normally surfaces — list views, agenda, dashboard traffic-light, project detail, CalDAV-synced calendars, and email reminders. Silence on a pending change creates more risk than visible-but-flagged-pending.
Out of scope for v1: notes, parties, documents, checklists; cross-app generalisation; multi-step n-of-m chains; email/WhatsApp/Telegram approvals (in-app only).
1. Context — what's already in the code
What this design slots into:
- Three-axis principle (m, t-paliad-051, sacrosanct). "Firm roles ≠ project roles ≠ tool roles."
paliad.users.job_title— free-text display. Never gates anything.paliad.users.global_role—standard|global_admin. Tool-admin gate only.paliad.project_teams.role—lead | associate | pa | of_counsel | local_counsel | expert | observer. Per-project membership role.
- Visibility:
paliad.can_see_project()SQL function (migration 023) + Go mirrorservices.visibilityPredicate()— global_admin OR any team membership on the project's path. Service-role connection bypasses RLS, so the Go mirror is load-bearing; RLS is defense-in-depth. - Audit:
paliad.project_events(created in migration 005 asakten_events, renamed in 018). Every mutation on every project-scoped entity emits one row viaservices.insertProjectEventWithMeta()inside the same tx. Carriesevent_type,title,description,metadata jsonb,created_by,event_date. Read byservices.AuditServiceand by the Verlauf card on each project / deadline / appointment detail page (t-paliad-097, t-paliad-102). - Entity tables:
paliad.deadlinesandpaliad.appointments. Both already carrycreated_by uuid REFERENCES auth.users(id). Deadlines havestatus text CHECK IN ('pending','completed','cancelled','waived'). Appointments have no status column. - Service layer:
DeadlineService.{Create,Update,Complete,Reopen,Delete},AppointmentService.{Create,Update,Delete}. Each goes throughProjectService.GetByID(ctx, userID, projectID)for visibility before mutating. Each emits its*_created/*_updated/*_completed/*_deletedevent in the same tx. - Existing patterns this design reuses:
paliad.partner_unit_eventsaudit table (migration 027) — proves the side-table-with-RLS shape works alongsideproject_events.paliad.event_types+paliad.deadline_event_types(migration 030) — the picker / multi-select / chip UI pattern is reusable for the "required role" select on the policy authoring page.services.visibilityPredicate(alias)— same shape for the newapprovalEligibleInProject(userID, projectID, requiredRole)helper.
This design adds no new auth/permission axis. It reuses project_teams.role for the qualification gate, per m's Q1 decision. The 3-axis principle holds because the gate uses the existing project axis, not a new firm-wide one.
2. Approval ladder
2.1 Strict ladder over project_teams.role
level | role | approval-eligible by default?
------+------------------+-------------------------------
5 | lead | yes — partner-tier on this project
4 | of_counsel | yes — senior tier
3 | associate | yes ← default required level
2 | senior_pa (new) | only if project policy lowers required to 'senior_pa' or below
1 | pa | only if project policy lowers required to 'pa'
0 | local_counsel | ineligible — external attorney, not in approval scope
0 | expert | ineligible — technical witness role
0 | observer | ineligible — read-only audit role
senior_pa is added to the paliad.project_teams.role CHECK constraint via migration 054 (see §6.1). It currently has no value in the enum.
Strict-ladder rule: a user with project_teams.role R can approve any request whose required_role is at level ≤ level(R). So:
- Default
required_role = 'associate'(level 3) → eligible approvers: lead, of_counsel, associate. - Override to
required_role = 'senior_pa'(level 2) → eligible: lead, of_counsel, associate, senior_pa. - Override to
required_role = 'pa'(level 1) → eligible: lead, of_counsel, associate, senior_pa, pa. This is the "PAs approve other PAs" mode m called for. - Override to
required_role = 'lead'→ only the project lead can approve.
Hard rules:
- Self-approval is hard-blocked.
caller_id = requested_byalways returns 403, regardless of role. This is enforced at the Go service layer (the only place that mutates approval state) and by a CHECK constraint on the row at decision time (approved_by != requested_by). - Eligible level 0 = ineligible. A user with role=local_counsel/expert/observer cannot approve any request, even if they're the only team member. They appear in the inbox with "Sie sind nicht qualifiziert" instead of the approve button.
global_adminis an explicit override path (§4.2) — not a normal approver. global_admin sign-off is allowed regardless of project_teams.role and audit-marked asdecision_kind='admin_override'.
2.2 Why not introduce a firm-wide qualification column?
The issue listed candidates partner / senior_attorney / attorney / senior_pa / pa / paralegal and asked whether roles should be global, per-team, or per-project. m chose per-project (Q1 = "Reuse project_teams.role"). Rationale (mine, before m chose; reproduced for the record):
A firm-wide rank column would have:
- Cleanly separated from
job_title(display) andglobal_role(tool admin). - Made authoring rules trivial — one column on
users, one int compare. - Worked even before a project's team was fully populated.
But it would have:
- Added a 4th identity-axis to maintain (firm rank), violating the spirit of the three-axis principle even if the letter holds.
- Forced a firm-wide ladder onto a project context where seniority is already encoded —
leadon a project IS the partner-tier on that project. - Introduced the question "what if firm rank disagrees with project role" (a senior partner staffed as
observeron a small case) without a clean answer.
m's per-project choice is consistent with how the rest of paliad treats authority: the lead role on project_teams is the source of truth for "who is the partner running this case", and approvals naturally cluster around that.
2.3 What about local_counsel / expert / observer?
Default: ineligible to approve. Rationale:
- local_counsel is an external attorney (Mitanwalt) — not always a firm employee, often outside the firm's approval chain.
- expert is a technical / scientific consultant role — not legally qualified to sign off on procedural deadlines.
- observer is explicitly a read-only role.
Escape hatch: if a project genuinely wants its local_counsel to approve, the team admin can re-add them with role='associate' (or whatever tier is intended). The role on project_teams is a per-project assignment; the same human can be local_counsel on Project A and associate on Project B if that's the correct authority on each.
Out of scope (follow-up if needed): a per-project list of "additional approval-eligible roles" that promotes local_counsel/expert into the eligible set without changing their primary project role. Probably not worth the complexity for the few cases where it'd matter.
3. Policy grammar — paliad.approval_policies
3.1 Schema
CREATE TABLE paliad.approval_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
UNIQUE (project_id, entity_type, lifecycle_event)
);
CREATE INDEX approval_policies_project_idx ON paliad.approval_policies (project_id);
Design choices:
- Up to 8 rows per project.
(deadline,create), (deadline,update), (deadline,complete), (deadline,delete), (appointment,create), (appointment,update), (appointment,complete), (appointment,delete). UNIQUE composite key enforces this. - No row = no approval needed for that event. A project with zero policy rows is in the same operational state as today — no 4-eye anywhere.
required_roleis a single value, not a min-level int. Stored as text matchingproject_teams.rolevalues; the strict ladder is applied in code (seelevelOf(role)in §3.4). Storing the enum value (rather than an int level) keeps the row readable inpsqland survives any future ladder reordering.- Appointment lifecycle includes
complete. Today appointments don't have acompleted_atcolumn or status field. We add one via migration 054 to giveappointment:completesomewhere to land — see §6.4. (m may choose to defer this; if so, the policy CHECK can dropcompleteforappointmentand the migration becomes lighter.)
3.2 Inheritance
No automatic inheritance from parent project. A child project (e.g. a single Verfahren under a Litigation parent) does NOT auto-inherit its parent's policy. Reasons:
- Inheriting would silently change behaviour when projects are reparented (t-paliad-018 already has reparent semantics).
- Policy authoring per-Verfahren is the right default — different stages of a litigation may legitimately need different scrutiny.
- The path-walking logic for "find the closest ancestor with policy" adds complexity for marginal value.
UI affordance: project detail → Settings → Approvals tab → "Aus Eltern-Projekt übernehmen" button copies the parent's 8 rows into this project. One-shot copy, no live link. Documented as a productivity shortcut.
3.3 Authoring permission
v1: global_admin only. Consistent with the existing /admin/team and /admin/partner-units pattern. Per-project leads cannot edit policy on their own projects in v1.
Reasoning: approval policy is firm-governance-grade — getting it wrong loosens compliance. Concentrating in global_admin is safer for v1. Lifting to "project lead can edit policy on their project" is a one-line gate change.
Out of scope follow-up: lead-can-edit-own-project-policy. File as t-paliad-139 if needed once the v1 ships.
3.4 Service-layer helpers
// internal/services/approval_levels.go
// levelOf maps a project_teams.role value to the strict-ladder level used
// for approval gating. Returns 0 (ineligible) for roles outside the
// approval ladder (local_counsel, expert, observer).
func levelOf(role string) int {
switch role {
case "lead": return 5
case "of_counsel": return 4
case "associate": return 3
case "senior_pa": return 2
case "pa": return 1
default: return 0 // local_counsel, expert, observer, anything new
}
}
// canApprove returns true iff:
// - caller is not the requester (self-approval blocked)
// - caller's project_teams.role on this project has level >= required level
// OR caller is global_admin (which is always allowed and audit-marked separately).
func (s *ApprovalService) canApprove(ctx, callerID, projectID, requiredRole string, requesterID uuid.UUID) (bool, kind string, err error) {
if callerID == requesterID {
return false, "", ErrSelfApprovalBlocked
}
user, err := s.users.GetByID(ctx, callerID)
if err != nil { return false, "", err }
if user.GlobalRole == "global_admin" {
return true, "admin_override", nil
}
membership, err := s.projects.MembershipFor(ctx, callerID, projectID)
if err != nil || membership == nil {
return false, "", nil // not on team, cannot approve
}
if levelOf(membership.Role) >= levelOf(requiredRole) {
return true, "peer", nil
}
return false, "", nil
}
decision_kind values: peer (normal in-team sign-off), admin_override (global_admin used override path). Stored on approval_requests.decision_kind.
4. Lifecycle flow (write-then-approve)
4.1 The four lifecycle events
For each entity (deadline, appointment), four lifecycle events trigger an approval check:
- create — new row submitted by user.
- update — change to one or more date-bearing fields (allowlist in §4.5).
- complete — flip status from
pendingtocompletedon a deadline; flip newcompleted_at(see §6.4) on appointment. - delete — request to remove the row.
4.2 Submission
User clicks Save / Complete / Delete on the entity. The service layer:
- Looks up
paliad.approval_policies(project_id, entity_type, event). - No row found: apply mutation immediately (today's behaviour).
approval_statusdefaults to'approved'. No request row written. Done. - Row found: apply mutation except for delete (see §4.3) and additionally:
- Set
approval_status = 'pending'andpending_request_id = <new uuid>on the entity row. - Insert one
paliad.approval_requestsrow withlifecycle_event,pre_image jsonb(a snapshot of the now-overwritten field values, used for revert on rejection — see §4.4),payload jsonb(echo of what was submitted, for audit),requested_by = caller,requested_at = now(),required_role = policy.required_role,status = 'pending'. - Emit
paliad.project_eventsrow withevent_type = 'deadline_approval_requested'(orappointment_approval_requested) carryingmetadata.approval_request_id = <uuid>. The Verlauf shows the lifecycle inline. - All four writes happen in one transaction (entity update + request insert + event emit).
- Set
- Single-qualified-approver deadlock check. Before committing, the service runs a count: how many users on this project's team have
levelOf(project_teams.role) >= levelOf(required_role) AND user_id != caller? If 0, the submission fails with HTTP 409 and a structured error:{ "error": "no_qualified_approver", "required_role": "associate", "hint": "add_team_member_or_contact_admin" }. Frontend translates to a user-facing dialog with two action buttons: "Mehr Team-Mitglieder hinzufügen" (jumps to project team page) and "Admin kontaktieren" (mailto link to global_admin emails). global_admin override is the escape hatch (§4.7).
4.3 Delete is special — stage-then-write
m's chosen architecture is write-then-approve, but delete cannot be applied immediately and reverted: a hard-delete is irrecoverable.
Resolution: for lifecycle_event = 'delete', the entity row stays in place. We set approval_status = 'pending' and link to an approval_requests row carrying lifecycle_event = 'delete'. The UI marks the row "Zur Löschung beantragt" (see §5.3). On approve: hard-delete the row in a tx (cascades clean up the FK from approval_requests). On reject: clear approval_status back to 'approved' and pending_request_id to NULL. The deletion never happened.
This is the one departure from pure write-then-approve. It's a write-then-approve from the user's perspective (they "submit a delete" and the entity behaves as if it's about to disappear) but at the data-layer it's stage-then-write for delete. Documented explicitly to avoid surprise.
4.4 Approval / rejection
Approver opens /inbox, picks a request, clicks Approve (or Reject with optional reason).
Approve:
- Service-layer
canApprove(caller, project, request)check (see §3.4). - If
decision_kind = 'peer'or'admin_override', setapproval_requests.status = 'approved',decided_by = caller,decided_at = now(),decision_kind = …. - Update entity row:
approval_status = 'approved', clearpending_request_id. Setapproved_by = caller,approved_at = now(). - For
delete: hard-delete the entity (cascade clears the request FK). - Emit
paliad.project_eventsrow withevent_type = 'deadline_approval_approved'(orappointment_approval_approved) carryingmetadata.approval_request_id,metadata.decision_kind. Verlauf line: "Frist X — Genehmigung erteilt von Bert · 2026-05-06". - Tx commits.
Reject:
- Same
canApprovecheck. - Set
approval_requests.status = 'rejected',decided_by,decided_at,decision_note(optional reason text from approver). - Revert entity — restore from
pre_image:create: hard-delete the entity (it never should have been live).update: writepre_imagefield values back over the row.complete: revert deadlinestatusto'pending', NULLcompleted_at. Revert appointmentcompleted_atto NULL (only meaningful once §6.4 lands).delete: clearpending_request_idandapproval_status. Entity stays live as before.
- Emit
paliad.project_eventsrowevent_type = 'deadline_approval_rejected'(or appointment_) withmetadata.approval_request_id,metadata.decision_note. Verlauf line: "Frist X — Genehmigung abgelehnt von Bert · 2026-05-06 — Grund: Datum noch nicht best." - Tx commits.
4.5 Edit-trigger field allowlist (per Q4)
The service layer only enters the approval-request flow when an update touches the date-bearing fields. All other edits apply immediately as approval_status='approved' writes — no request row, no pending state.
Deadlines — date-bearing (gates approval):
due_dateoriginal_due_datewarning_date
Deadlines — bypass (no approval):
title,description,notesrule_id,rule_code(legal-basis citation — m chose to bypass; see Q4 trade-off below)event_type_ids(Typ tags viapaliad.deadline_event_typesjunction)statusother than via thecompletelifecycle (e.g. cancel, waive — these are out of approval scope per the issue's "all four lifecycle events" framing, which lists complete but not cancel/waive)
Appointments — date-bearing (gates approval):
start_atend_at
Appointments — bypass (no approval):
title,descriptionlocation(m's Q4 choice excludes location; documented trade-off below)appointment_type
Trade-off (m's call): the looser allowlist means a wrongful change to rule_code (legal basis) or location (wrong courthouse) won't trigger 4-eye. m's reasoning is implicit but consistent: dates are the highest-stakes mistake category (missed deadline = malpractice exposure), and gating every metadata edit creates approval fatigue that makes approvers rubber-stamp.
If the team finds this allowlist too loose in practice, the constants in internal/services/approval_fields.go (proposed location) are a one-PR widening — no schema change.
4.6 Optimistic-concurrency / superseded requests
Race scenario: User A submits an update request with pre_image = {due_date: 2026-05-10}. Before it's approved, user B submits another update with their own pre-image. Now there are two pending requests on the same row.
Rule: a row can have at most one pending request at a time. The submission service-layer does:
UPDATE paliad.deadlines
SET ...new field values..., approval_status = 'pending', pending_request_id = $newRequestID
WHERE id = $entityID
AND approval_status = 'approved' -- only mutate if currently clean
RETURNING id;
If the UPDATE returns 0 rows (because approval_status != 'approved'), the submission fails with HTTP 409 { "error": "concurrent_pending", "hint": "wait_for_existing_approval_or_revoke" }. Frontend shows "Es liegt bereits eine Genehmigungsanfrage auf dieser Frist vor."
Submitter has options: revoke their own pending (if they own it) and resubmit; or wait for the existing request to settle.
4.7 Single-qualified-approver deadlock — global_admin override path
Per Q8, the default behaviour is refuse to submit when no qualified approver other than the requester exists on the team. Submission is blocked at the API layer.
Override mechanism: any global_admin (regardless of project membership) has the approval right. So if the user's team has nobody else qualified, the user can submit anyway IF the project has at least one global_admin who can approve. The submission service runs the deadlock check as:
SELECT COUNT(*) FROM paliad.project_teams pt
WHERE pt.project_id = $proj
AND pt.user_id <> $caller
AND pt.role IN (eligible roles for required_role)
+
SELECT COUNT(*) FROM paliad.users u
WHERE u.global_role = 'global_admin'
AND u.id <> $caller
If sum > 0, submission is allowed. If sum = 0, the 409 fires. In practice, paliad currently has 2 global_admins so sum is rarely 0 — but the design contemplates the case.
When global_admin signs off, the decision_kind on the approval_request row is 'admin_override' (vs 'peer'). Verlauf chronology renders this distinctly: "Admin-Sign-off von m · 2026-05-06" rather than "Genehmigt von Bert · 2026-05-06". The audit log timeline filters can pivot on decision_kind.
4.8 Revocation (per Q7)
- Requester revokes: while
request.status = 'pending', the requester can DELETE their own request. Service-layer reverts the entity from pre_image (same code path as Reject), but instead of marking the request'rejected', marks it'revoked'. Newpaliad.project_eventsevent_type'deadline_approval_revoked'. - Approver revokes after approval: not supported per Q7. Once approved, the only path back is a new request — e.g. an over-eager Complete is reversed by a fresh "Reopen" lifecycle event, which itself flows through the approval gate.
5. UI surfaces
5.1 The pending pill — visible everywhere
Per m's interjection, pending state must surface in every view that shows the entity. Visual treatment:
- Pending CREATE — striped/dashed border on the row, ⚠ icon, label "Erstellung wartet auf Genehmigung von <required_role>+". Counted toward traffic-light buckets (the deadline IS real, just unverified) but rendered with a "tentative" CSS class.
- Pending UPDATE — solid border, but a yellow chip in the date column saying "Datum geändert — wartet auf Genehmigung". Tooltip on the chip shows the diff: "vorher: 2026-05-10 → 2026-05-12".
- Pending COMPLETE — solid border, status badge "Erledigt (wartet auf Genehmigung)" with strike-through-pending styling. The traffic-light treats the row as completed (the action-taker thinks they're done) but with the same striped class as create-pending so an approver can see the queue at a glance.
- Pending DELETE — dashed-red border, label "Zur Löschung beantragt". Date / details still visible but strike-through. Click → details + approval request.
CSS classes (proposed, in frontend/src/styles/global.css):
.entity-row--pending-create { border-style: dashed; border-color: var(--frist-amber); }
.entity-row--pending-update { /* solid border, chip handles the signal */ }
.entity-row--pending-complete { background: linear-gradient(...striped...); }
.entity-row--pending-delete { border-style: dashed; border-color: var(--frist-red); text-decoration: line-through; }
.approval-pill { display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 9999px;
background: var(--bg-warn-soft); color: var(--fg-warn);
font-size: 12px; }
.approval-pill::before { content: "⚠ "; }
i18n keys (DE primary, EN secondary):
approvals.pending_create.label— "Erstellung wartet auf Genehmigung" / "Awaits approval (creation)"approvals.pending_update.label— "Änderung wartet auf Genehmigung" / "Awaits approval (change)"approvals.pending_complete.label— "Erledigung wartet auf Genehmigung" / "Awaits approval (completion)"approvals.pending_delete.label— "Zur Löschung beantragt" / "Awaits approval (deletion)"approvals.required_role.<role>— "Lead", "Of Counsel", "Associate", "Senior PA", "PA"approvals.requested_by— "Eingereicht von {name}" / "Submitted by {name}"approvals.no_approver_dialog.*— full deadlock dialog stringsapprovals.approve.button— "Genehmigen" / "Approve"approvals.reject.button— "Ablehnen" / "Reject"approvals.revoke.button— "Zurückziehen" / "Revoke"approvals.decision_kind.peer— "Genehmigt von {name}" / "Approved by {name}"approvals.decision_kind.admin_override— "Admin-Sign-off von {name}" / "Admin sign-off by {name}"
Surfaces that show the pending pill:
/deadlinesand/appointmentstable rows (one pill per row)./agendatimeline (per-row pill)./dashboardtraffic-light card-list previews./projects/{id}details — Fristen + Termine sections./deadlines/{id}and/appointments/{id}detail pages — full diff display.- CalDAV: pending entries sync to the user's external calendar with title prefix
[PENDING](e.g.[PENDING] Frist Erwiderung). Approved entries sync clean. - Email reminders (
internal/services/reminder_service.go): pending entries get a banner in the mail body and a[PENDING]subject prefix.
5.2 Bell + /inbox page (per Q6)
Bell in the sidebar header (next to the user-menu). Shows count of "open requests where I am a qualified approver and not the requester". Click → /inbox. Refreshes via the existing dashboard-polling pattern (60s interval; Last-Modified ETag if cheap to add).
/inbox page, two tabs:
-
"Zur Genehmigung" (
?tab=pending-mine): list ofapproval_requestswhere:status = 'pending'requested_by != me- I have eligible role on the project (or I'm global_admin)
Sorted by
requested_atASC (oldest first — stale requests demand attention). Each item shows: project title, entity title, lifecycle event, requester name, age ("vor 4h"), required-role badge. Inline Approve / Reject buttons, expand-row reveals the diff (for update / complete / delete) or full payload (for create).
-
"Meine Anfragen" (
?tab=mine): list ofapproval_requestswhererequested_by = me. Status filter pills: pending / approved / rejected / revoked. For pending items, a Revoke button.
URL structure: /inbox?tab=pending-mine|mine&status=pending|...&project_id=.... Back-button friendly.
Why distinct from email reminder flow: email reminders are outbound notifications (digest of upcoming deadlines). The inbox is a workflow surface — actions are taken there. Sharing infra would conflate two purposes.
5.3 Policy authoring — /projects/{id}/settings/approvals
Tab on the project detail page, gated to global_admin. Rendered as a 2×4 table:
CREATE UPDATE (date) COMPLETE DELETE
Frist [select] [select] [select] [select]
Termin [select] [select] [select] [select]
Each <select> offers: "Keine Genehmigung erforderlich (default)" / "Lead" / "Of Counsel" / "Associate" / "Senior PA" / "PA". Submitting upserts/deletes rows in paliad.approval_policies.
Helpers:
- "Aus Eltern-Projekt übernehmen" button — copies the parent project's policy rows in one click. One-shot copy, no live link.
- "Alle auf Associate setzen" button — fills all 8 cells with
associatefor fast onboarding of a new project.
5.4 Diff rendering
For update requests, the pre_image jsonb captured at submission and the entity's current values let the UI render a clean diff. For deadlines: a 1-3 line comparison ("Datum: 2026-05-10 → 2026-05-12 · Warnung: 2026-05-08 → 2026-05-10"). Done in pure TS in frontend/src/client/inbox.ts consuming the request payload.
6. Schema changes (migration 054)
6.1 Add senior_pa to project_teams.role
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
CHECK (role IN (
'lead','associate','pa','of_counsel',
'local_counsel','expert','observer',
'senior_pa'
));
i18n labels for the new role (in DE+EN per existing team.role.* keys).
6.2 paliad.approval_policies
See §3.1 — full DDL.
6.3 paliad.approval_requests
CREATE TABLE paliad.approval_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
-- entity_id is the deadline.id / appointment.id this request operates on.
-- For 'create' lifecycle, this is the id of the just-inserted entity row
-- (so the request can reference back to it). For 'delete', it's the row
-- being requested for removal.
entity_id uuid NOT NULL,
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
-- For 'update'/'complete'/'delete': pre_image carries the field values
-- needed to revert on rejection. For 'create': pre_image IS NULL.
pre_image jsonb,
-- For audit/visibility, payload echoes the diff or new values that were
-- written. Read-only after insert.
payload jsonb,
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
requested_at timestamptz NOT NULL DEFAULT now(),
-- Snapshot of policy.required_role at request time. Even if the policy
-- changes mid-flight, the request honours the level it was submitted under.
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','rejected','revoked','superseded')),
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
decided_at timestamptz,
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer','admin_override')),
decision_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Hard CHECK: an approver is never the requester.
CHECK (decided_by IS NULL OR decided_by <> requested_by)
);
CREATE INDEX approval_requests_project_status_idx
ON paliad.approval_requests (project_id, status);
CREATE INDEX approval_requests_entity_idx
ON paliad.approval_requests (entity_type, entity_id);
CREATE INDEX approval_requests_requested_by_idx
ON paliad.approval_requests (requested_by, status);
CREATE INDEX approval_requests_pending_idx
ON paliad.approval_requests (status, requested_at)
WHERE status = 'pending';
RLS on approval_requests: per Q10, mirror paliad.deadlines policy — visible if paliad.can_see_project(project_id). RLS does NOT gate the approve/reject action; that's enforced at the service layer.
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY approval_requests_all ON paliad.approval_requests
FOR ALL USING (paliad.can_see_project(project_id));
6.4 New columns on paliad.deadlines and paliad.appointments
-- deadlines: approval state + approver tracking
ALTER TABLE paliad.deadlines ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved','pending','legacy'));
ALTER TABLE paliad.deadlines ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
ALTER TABLE paliad.deadlines ADD COLUMN approved_by uuid
REFERENCES paliad.users(id) ON DELETE SET NULL;
ALTER TABLE paliad.deadlines ADD COLUMN approved_at timestamptz;
CREATE INDEX deadlines_approval_status_idx
ON paliad.deadlines (approval_status) WHERE approval_status = 'pending';
-- appointments: same triple
ALTER TABLE paliad.appointments ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved','pending','legacy'));
ALTER TABLE paliad.appointments ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
ALTER TABLE paliad.appointments ADD COLUMN approved_by uuid
REFERENCES paliad.users(id) ON DELETE SET NULL;
ALTER TABLE paliad.appointments ADD COLUMN approved_at timestamptz;
-- appointments need a completed_at for the 'complete' lifecycle event to land
ALTER TABLE paliad.appointments ADD COLUMN completed_at timestamptz;
CREATE INDEX appointments_approval_status_idx
ON paliad.appointments (approval_status) WHERE approval_status = 'pending';
appointments.completed_at is new. Today appointments don't have a completion concept — they just sit on the calendar. The complete lifecycle event for appointments is meaningful when m wants to mark hearings/meetings as actually-happened (e.g. "Mündliche Verhandlung am 2026-05-15 — abgehalten"). If m prefers to drop appointment-complete from the lifecycle list (deadline-complete only), the completed_at column drops out and the policy CHECK constraint excludes (appointment, complete).
This is a clean place for m to make a smaller call: keep appointment:complete (and add completed_at), or drop it.
6.5 Backfill
-- Mark all existing rows as legacy (predates 4-eye).
UPDATE paliad.deadlines SET approval_status = 'legacy';
UPDATE paliad.appointments SET approval_status = 'legacy';
approved_by/approved_at stay NULL on legacy rows. created_by is already populated since migration 005 (the column has been required from day one).
No retroactive approval — m's Q11 choice. Legacy rows are read-only-clean. The next mutation on a legacy row that hits an active policy follows the normal flow (e.g. editing a date on a legacy deadline triggers update approval; the row becomes approval_status='pending' and goes through the gate; once approved, approval_status='approved').
6.6 Down migration
The down migration drops the four new columns + completed_at + approval_policies + approval_requests + restores the project_teams.role CHECK without senior_pa. If any user has been re-roled to senior_pa, the down migration will fail loudly until they're migrated to another role — intentional, mirrors the t-paliad-051 down strategy.
7. Service-layer integration
7.1 New service: ApprovalService
// internal/services/approval_service.go
type ApprovalService struct {
db *sqlx.DB
projects *ProjectService
users *UserService
}
// SubmitCreate is invoked by DeadlineService.Create / AppointmentService.Create
// inside the existing entity-write tx. If a policy applies, it inserts the
// approval_requests row and sets entity.approval_status = 'pending' + entity.
// pending_request_id. Returns (requestID, isPending, err).
func (s *ApprovalService) SubmitCreate(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
// Same shape for Update / Complete / Delete. Update takes a preImage map.
func (s *ApprovalService) SubmitUpdate(ctx, tx, projectID, entityType, entityID, requesterID, preImage map[string]any) (uuid.UUID, bool, error)
func (s *ApprovalService) SubmitComplete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
func (s *ApprovalService) SubmitDelete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
// Approve / Reject / Revoke — invoked by the inbox handler.
func (s *ApprovalService) Approve(ctx, requestID, callerID, note string) error
func (s *ApprovalService) Reject(ctx, requestID, callerID, note string) error
func (s *ApprovalService) Revoke(ctx, requestID, callerID string) error
// ListInbox returns the pending-mine and my-submitted views.
func (s *ApprovalService) ListPendingForApprover(ctx, callerID, filter) ([]ApprovalRequestView, error)
func (s *ApprovalService) ListSubmittedByUser(ctx, callerID, filter) ([]ApprovalRequestView, error)
7.2 Wiring into existing services
DeadlineService.Create today:
- ProjectService.GetByID gate (visibility check)
- Begin tx
- INSERT into paliad.deadlines
- Attach event_types junction rows
- insertProjectEventWithMeta(deadline_created)
- Commit
After integration:
- ProjectService.GetByID gate
- Begin tx
- INSERT into paliad.deadlines (approval_status defaults to 'approved')
approvals.SubmitCreate(ctx, tx, projectID, "deadline", id, userID)— if policy applies, this:- Updates approval_status='pending', pending_request_id=… on the just-inserted row
- INSERTs approval_requests row
- Performs deadlock count, fails the tx if 0 qualified approvers exist
- Attach event_types junction rows
- insertProjectEventWithMeta(deadline_created) — unchanged
- insertProjectEventWithMeta(deadline_approval_requested) if approval is pending
- Commit
Same shape for Update, Complete, Delete on both DeadlineService and AppointmentService. The Complete call site is MarkComplete/Reopen in DeadlineService (today); reopen would be modelled as a fresh "create-style" approval if it lands on a legacy row, or as part of "update" lifecycle on the status field — but status is not in the date-bearing allowlist so reopen goes through immediately. Reopen does NOT trigger 4-eye under this design (Q4 = date-fields-only). If m wants reopen-needs-approval, add status to the allowlist or treat reopen as its own lifecycle event.
7.3 Read-path changes
Existing list/summary queries (ListVisibleForUser, SummaryCounts) need to:
- Hydrate
approval_status,approved_by,approved_at, and the linkedapproval_requests.lifecycle_event(via JOIN) for each row. - Pass these through to the frontend so the pending pill and traffic-light styling can render.
Bucket math (t-paliad-106 5-bucket harmonisation) is unchanged — pending CREATEs still bucket by due_date like any other; the visual just adds the pending pill. Pending DELETEs still appear in their bucket until the delete is approved.
/api/inbox/pending-mine and /api/inbox/mine are new endpoints, served by internal/handlers/inbox.go.
7.4 Visibility gating for the inbox
The pending-mine list is gated by:
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 role on this specific project
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 levelOf(pt.role) >= levelOf(ar.required_role))
)
ORDER BY ar.requested_at ASC;
levelOf in SQL is a small immutable function:
CREATE FUNCTION paliad.approval_role_level(role text) RETURNS int LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE role
WHEN 'lead' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
ELSE 0
END
$$;
Stable values; mirrors the Go levelOf. Used in the inbox SQL and in any future RLS policy. Migration ships both.
8. Audit / chronology integration
Per Q9, the existing paliad.project_events audit gains four new event_type values per entity:
deadline_approval_requested— a request was submitted. Metadata:{ approval_request_id, lifecycle_event, required_role }.deadline_approval_approved— request approved. Metadata:{ approval_request_id, decision_kind, decided_by_email }.deadline_approval_rejected— request rejected. Metadata:{ approval_request_id, decision_note }.deadline_approval_revoked— requester revoked their own pending. Metadata:{ approval_request_id }.
Same four for appointments (appointment_approval_*).
These appear in:
- The
paliad.project_eventsVerlauf card on/projects/{id}(via existing render path; new translateEvent cases needed infrontend/src/client/projects-detail.ts). - The
paliad.project_eventsVerlauf card on/deadlines/{id}and/appointments/{id}(same pattern). - The cross-project
AuditService.ListEntriestimeline at/admin/audit-log(already unions project_events; new event types ride along automatically). - Dashboard recent-activity rail (filter through existing
translateEventto render the correct sentence).
Both names persist on the entity per the issue's m-locked requirement: created_by (already there) + approved_by (new). Verlauf renders for an approved deadline:
Frist erstellt — eingereicht von Anna 2026-05-06 14:23
· genehmigt von Bert 2026-05-06 14:31
This is two project_events rows rendered as a paired card in the Verlauf. The frontend pairs them by metadata.approval_request_id.
9. RLS / security plan
Per Q10:
approval_requests— RLS =paliad.can_see_project(project_id). Same predicate asdeadlines/appointments. Anyone on the project can read pending requests (transparency).approval_policies— RLS =paliad.can_see_project(project_id)for SELECT; INSERT/UPDATE/DELETE gated toglobal_role = 'global_admin'(consistent with /admin/team / /admin/partner-units precedent).- The
approve/reject/revokeaction — service-layer gate only. The pgx pool runs as service role and bypasses RLS, so the check happens inApprovalService.canApprove()(§3.4). RLS provides defense-in-depth for any future direct-DB query path. - Self-approval block — enforced both at the service layer and via a CHECK constraint on
approval_requests(decided_by IS NULL OR decided_by <> requested_by). Two layers because either alone is insufficient (a SQL bug bypasses the service; a service bug bypasses the CHECK).
The path-walking team-membership + global_admin predicate (visibilityPredicate) extends naturally to "approvable-by-me" via the inline JOIN shown in §7.4. No new SQL function needed; the inline form is read-only on the inbox query path.
Out of scope follow-up: if any future direct-DB tooling needs to query "approvable by me", extract a paliad.can_approve_in_project(user_id, project_id, required_role) SQL function. For v1, the inline JOIN is sufficient and avoids adding a function that no migration currently calls.
10. Migration plan
10.1 Single migration, single PR
Migration 054 (054_approvals.{up,down}.sql):
- Add
senior_patoproject_teams.roleCHECK (§6.1). - Create
paliad.approval_role_level(text) RETURNS intSQL function. - Create
paliad.approval_policiestable (§6.2) + indexes + RLS. - Create
paliad.approval_requeststable (§6.3) + indexes + RLS. - Add new columns on
paliad.deadlinesandpaliad.appointments(§6.4) + indexes. - Mark all existing rows
approval_status='legacy'(§6.5).
No data move. No FK hijinks. ms-level apply on a 200-ish-row deadlines table.
10.2 Implementation phasing
The PR is large but clean. Recommended split into commits (single branch, single PR):
- Commit 1 — Migration 054. Schema + backfill. No code changes. Runs cleanly on prod; existing flows don't read the new columns yet.
- Commit 2 —
ApprovalServicecore. Submit / Approve / Reject / Revoke, deadlock check, pre_image capture, request lifecycle. Unit tests (table-driven over the strict-ladder + self-approval rules, deadlock count edge cases). - Commit 3 — Wire into
DeadlineService+AppointmentService. Mutation paths gain the SubmitCreate/Update/Complete/Delete hooks. Read paths hydrate approval_status. Adds new event_types to project_events emit path. Live-DB integration test: TEST_DATABASE_URL covering submit→approve / submit→reject / submit→revoke / single-approver-deadlock / global-admin-override. - Commit 4 — Policy authoring page.
/projects/{id}/settings/approvalstab + handler + frontend. global_admin-only gate. - Commit 5 — Inbox.
/inboxpage + bell icon +/api/inbox/*endpoints + frontend list rendering with diff display. - Commit 6 — Pending pills + traffic-light variants. CSS + i18n + per-surface pill rendering on /deadlines, /appointments, /agenda, /dashboard, /projects/{id}, detail pages.
- Commit 7 — CalDAV
[PENDING]prefix + email-reminder pending banner. Updatescaldav_service.goandmail_service.goformatting. Integration tests on iCal output and rendered email body. - Commit 8 — Verlauf rendering of approval lifecycle. translateEvent cases for the four new event_types. Pair-card rendering for request+decision events.
Each commit is testable in isolation; commits 1–3 are merge-safe even before the UI lands (legacy rows + pending state hidden by default = no behaviour change on existing flows because no project has policies until commit 4 ships).
10.3 Roll-out
Suggested:
- Migration 054 lands → no behaviour change (no policies exist yet).
- Pick one pilot project, set policy
(deadline,*)=associate. Smoke through one CREATE / UPDATE / COMPLETE / DELETE cycle as a non-admin user. Verify pending pills, inbox, approver flow, audit chronology. - Once validated, m authors policies on real client projects. Each project opts in by adding rows.
- Backfill any free-form leftover later if needed (admin scripts).
11. Trade-offs and known limitations
11.1 Write-then-approve dilution risk
Per Q5 m chose write-then-approve. This means a pending CREATE is "live" in lists / dashboard / agenda / CalDAV / email reminders before approval. A wrongful create that's eventually rejected briefly polluted the user's mental model and external calendars.
Mitigations:
- Pending pill is highly visible (striped border, ⚠ icon).
- CalDAV title prefix
[PENDING]makes external surfaces honest. - Rejected creates emit
*_approval_rejectedevent in Verlauf so the "what happened to that deadline" question has a paper trail. - Approval flow surfaces immediately in inbox (bell badge), so latency between submit and approve is short.
The alternative (stage-then-write) was strictly safer but m rejected it; the strict-safer architecture would have forced each Frist to live in approval_requests until approved, which means views had to UNION the entity table with the requests table — heavy read-path changes and the kind of complexity that compounds into bugs.
11.2 Date-fields-only edit allowlist
m chose Q4 = "Only date-changing fields". Trade-off: a wrongful change to rule_code (legal basis) or location (wrong courthouse) bypasses 4-eye. The ladder-based approval-fatigue argument (every metadata edit triggering approvals causes rubber-stamping) is the case for the looser scope.
If the team finds this too loose in practice, extending the allowlist is a one-line constants change in internal/services/approval_fields.go — documented as the place to widen.
11.3 No inheritance from parent project
§3.2 — a child project doesn't auto-inherit its parent's policy. Trade-off: explicit per-project authoring (more control, more clicks). The "Aus Eltern-Projekt übernehmen" button in the authoring UI (§5.3) reduces the friction.
11.4 v1 is global_admin-only for policy authoring
Per §3.3, only global_admins can create/edit policies. Project leads cannot edit their own project's policy. Trade-off: tighter governance vs. lead self-service. Lifting to "lead can edit" is a one-line gate change (file as t-paliad-139).
11.5 senior_pa is the only new role enum value
§6.1 only adds senior_pa. Other firm-rank candidates from the issue (partner, senior_attorney, attorney, paralegal) were redundant: lead already represents partner-tier on a project, of_counsel covers senior-attorney, associate covers attorney, and paralegal sits below pa (mapped to observer in v1). If those distinctions matter later, additional values can be added without breaking existing rows.
11.6 Reopen is not a separate lifecycle
Today reopening a deadline (revert from completed to pending) is a status-only change. With Q4 = date-fields-only, reopen does NOT trigger 4-eye. If m wants reopen-needs-approval, it can be modelled as a 5th lifecycle event or as a special-case status-field entry in the allowlist. Documented for future tightening.
11.7 Approval timeout
No automatic timeout on pending requests. A request can sit pending forever. UI surfaces age ("vor 4 Tagen") to nudge approvers. Future addition: nightly digest email to approvers with a list of pending items > 24h old. Out of scope for v1.
12. Implementation recommendation
Recommended implementer: cronus (this same worktree). Rationale: shipped t-paliad-088 (Event Types — schema + service + handlers + frontend, similar shape), t-paliad-110 (events unification — read-path with new columns hydrated and rendered), t-paliad-122 (courts entity with role-tier-like ladder over countries+regimes). Pattern fluency is high.
Alternative: split — cronus does commits 1–3 (schema + service core + service-layer wiring) on mai/cronus/approvals-impl-1. Then a fresh coder (curie or fritz) does commits 4–8 (UI + inbox + pills + CalDAV + email) on a sibling branch. Trade-off: smaller PRs, but two coordination handovers.
Head decides.
13. End-of-design checklist
- Locked constraints summarised (§0)
- Existing-code grounding (§1)
- Role taxonomy / hierarchy (§2)
- Rule grammar (§3)
- Lifecycle flow + edit allowlist + deadlock + revocation (§4)
- UI surfaces (§5)
- Schema (§6)
- Service-layer integration (§7)
- Audit / chronology (§8)
- RLS / security (§9)
- Migration plan + phasing (§10)
- Trade-offs (§11)
- Implementation recommendation (§12)
Inventor stays parked. Design committed; awaiting m's go/no-go before any coder shift starts. No /mai-coder self-load. The DESIGN READY FOR REVIEW signal is sent via mai report completed so the head can gate.