# 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 mirror `services.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 as `akten_events`, renamed in 018). Every mutation on every project-scoped entity emits one row via `services.insertProjectEventWithMeta()` inside the same tx. Carries `event_type`, `title`, `description`, `metadata jsonb`, `created_by`, `event_date`. Read by `services.AuditService` and by the Verlauf card on each project / deadline / appointment detail page (t-paliad-097, t-paliad-102).
- **Entity tables:** `paliad.deadlines` and `paliad.appointments`. Both already carry `created_by uuid REFERENCES auth.users(id)`. Deadlines have `status 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 through `ProjectService.GetByID(ctx, userID, projectID)` for visibility before mutating. Each emits its `*_created` / `*_updated` / `*_completed` / `*_deleted` event in the same tx.
- **Existing patterns this design reuses:**
  - `paliad.partner_unit_events` audit table (migration 027) — proves the side-table-with-RLS shape works alongside `project_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 new `approvalEligibleInProject(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:**

1. **Self-approval is hard-blocked.** `caller_id = requested_by` always 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`).
2. **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.
3. **`global_admin` is an explicit override path** (§4.2) — not a normal approver. global_admin sign-off is allowed regardless of project_teams.role and audit-marked as `decision_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) and `global_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 — `lead` on 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 `observer` on 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

```sql
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_role` is a single value**, not a min-level int. Stored as text matching `project_teams.role` values; the strict ladder is applied in code (see `levelOf(role)` in §3.4). Storing the enum value (rather than an int level) keeps the row readable in `psql` and survives any future ladder reordering.
- **Appointment lifecycle includes `complete`**. Today appointments don't have a `completed_at` column or status field. We add one via migration 054 to give `appointment:complete` somewhere to land — see §6.4. (m may choose to defer this; if so, the policy CHECK can drop `complete` for `appointment` and 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

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

1. **create** — new row submitted by user.
2. **update** — change to one or more date-bearing fields (allowlist in §4.5).
3. **complete** — flip status from `pending` to `completed` on a deadline; flip new `completed_at` (see §6.4) on appointment.
4. **delete** — request to remove the row.

### 4.2 Submission

User clicks Save / Complete / Delete on the entity. The service layer:

1. Looks up `paliad.approval_policies(project_id, entity_type, event)`.
2. **No row found:** apply mutation immediately (today's behaviour). `approval_status` defaults to `'approved'`. No request row written. Done.
3. **Row found:** apply mutation **except for delete** (see §4.3) and additionally:
   - Set `approval_status = 'pending'` and `pending_request_id = <new uuid>` on the entity row.
   - Insert one `paliad.approval_requests` row with `lifecycle_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_events` row with `event_type = 'deadline_approval_requested'` (or `appointment_approval_requested`) carrying `metadata.approval_request_id = <uuid>`. The Verlauf shows the lifecycle inline.
   - All four writes happen in **one transaction** (entity update + request insert + event emit).
4. **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:**

1. Service-layer `canApprove(caller, project, request)` check (see §3.4).
2. If `decision_kind = 'peer'` or `'admin_override'`, set `approval_requests.status = 'approved'`, `decided_by = caller`, `decided_at = now()`, `decision_kind = …`.
3. Update entity row: `approval_status = 'approved'`, clear `pending_request_id`. Set `approved_by = caller`, `approved_at = now()`.
4. For `delete`: hard-delete the entity (cascade clears the request FK).
5. Emit `paliad.project_events` row with `event_type = 'deadline_approval_approved'` (or `appointment_approval_approved`) carrying `metadata.approval_request_id`, `metadata.decision_kind`. Verlauf line: "Frist X — Genehmigung erteilt von Bert · 2026-05-06".
6. Tx commits.

**Reject:**

1. Same `canApprove` check.
2. Set `approval_requests.status = 'rejected'`, `decided_by`, `decided_at`, `decision_note` (optional reason text from approver).
3. **Revert entity** — restore from `pre_image`:
   - `create`: hard-delete the entity (it never should have been live).
   - `update`: write `pre_image` field values back over the row.
   - `complete`: revert deadline `status` to `'pending'`, NULL `completed_at`. Revert appointment `completed_at` to NULL (only meaningful once §6.4 lands).
   - `delete`: clear `pending_request_id` and `approval_status`. Entity stays live as before.
4. Emit `paliad.project_events` row `event_type = 'deadline_approval_rejected'` (or appointment_) with `metadata.approval_request_id`, `metadata.decision_note`. Verlauf line: "Frist X — Genehmigung abgelehnt von Bert · 2026-05-06 — Grund: Datum noch nicht best."
5. 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_date`
- `original_due_date`
- `warning_date`

**Deadlines — bypass (no approval):**
- `title`, `description`, `notes`
- `rule_id`, `rule_code` (legal-basis citation — m chose to bypass; see Q4 trade-off below)
- `event_type_ids` (Typ tags via `paliad.deadline_event_types` junction)
- `status` other than via the `complete` lifecycle (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_at`
- `end_at`

**Appointments — bypass (no approval):**
- `title`, `description`
- `location` (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:

```sql
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'`. New `paliad.project_events` event_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`):

```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 strings
- `approvals.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:

- `/deadlines` and `/appointments` table rows (one pill per row).
- `/agenda` timeline (per-row pill).
- `/dashboard` traffic-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:

1. **"Zur Genehmigung"** (`?tab=pending-mine`): list of `approval_requests` where:
   - `status = 'pending'`
   - `requested_by != me`
   - I have eligible role on the project (or I'm global_admin)
   Sorted by `requested_at` ASC (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).

2. **"Meine Anfragen"** (`?tab=mine`): list of `approval_requests` where `requested_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 `associate` for 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`

```sql
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`

```sql
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.

```sql
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`

```sql
-- 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

```sql
-- 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`

```go
// 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:
1. ProjectService.GetByID gate (visibility check)
2. Begin tx
3. INSERT into paliad.deadlines
4. Attach event_types junction rows
5. insertProjectEventWithMeta(deadline_created)
6. Commit

After integration:
1. ProjectService.GetByID gate
2. Begin tx
3. INSERT into paliad.deadlines (approval_status defaults to 'approved')
4. **`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
5. Attach event_types junction rows
6. insertProjectEventWithMeta(deadline_created) — unchanged
7. **insertProjectEventWithMeta(deadline_approval_requested)** if approval is pending
8. 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 linked `approval_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:

```sql
SELECT ar.* FROM paliad.approval_requests ar
  JOIN paliad.projects p ON p.id = ar.project_id
 WHERE ar.status = 'pending'
   AND ar.requested_by != $callerID
   AND <visibilityPredicate>(p) for callerID
   AND (
     -- caller is global_admin
     EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $callerID AND u.global_role = 'global_admin')
     OR
     -- caller has eligible 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:

```sql
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_events` Verlauf card on `/projects/{id}` (via existing render path; new translateEvent cases needed in `frontend/src/client/projects-detail.ts`).
- The `paliad.project_events` Verlauf card on `/deadlines/{id}` and `/appointments/{id}` (same pattern).
- The cross-project `AuditService.ListEntries` timeline at `/admin/audit-log` (already unions project_events; new event types ride along automatically).
- Dashboard recent-activity rail (filter through existing `translateEvent` to 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:

1. **`approval_requests`** — RLS = `paliad.can_see_project(project_id)`. Same predicate as `deadlines`/`appointments`. Anyone on the project can read pending requests (transparency).
2. **`approval_policies`** — RLS = `paliad.can_see_project(project_id)` for SELECT; INSERT/UPDATE/DELETE gated to `global_role = 'global_admin'` (consistent with /admin/team / /admin/partner-units precedent).
3. **The `approve`/`reject`/`revoke` action** — service-layer gate only. The pgx pool runs as service role and bypasses RLS, so the check happens in `ApprovalService.canApprove()` (§3.4). RLS provides defense-in-depth for any future direct-DB query path.
4. **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`):

1. Add `senior_pa` to `project_teams.role` CHECK (§6.1).
2. Create `paliad.approval_role_level(text) RETURNS int` SQL function.
3. Create `paliad.approval_policies` table (§6.2) + indexes + RLS.
4. Create `paliad.approval_requests` table (§6.3) + indexes + RLS.
5. Add new columns on `paliad.deadlines` and `paliad.appointments` (§6.4) + indexes.
6. 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):

1. **Commit 1 — Migration 054.** Schema + backfill. No code changes. Runs cleanly on prod; existing flows don't read the new columns yet.
2. **Commit 2 — `ApprovalService` core.** 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).
3. **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.
4. **Commit 4 — Policy authoring page.** `/projects/{id}/settings/approvals` tab + handler + frontend. global_admin-only gate.
5. **Commit 5 — Inbox.** `/inbox` page + bell icon + `/api/inbox/*` endpoints + frontend list rendering with diff display.
6. **Commit 6 — Pending pills + traffic-light variants.** CSS + i18n + per-surface pill rendering on /deadlines, /appointments, /agenda, /dashboard, /projects/{id}, detail pages.
7. **Commit 7 — CalDAV `[PENDING] ` prefix + email-reminder pending banner.** Updates `caldav_service.go` and `mail_service.go` formatting. Integration tests on iCal output and rendered email body.
8. **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:

1. Migration 054 lands → no behaviour change (no policies exist yet).
2. 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.
3. Once validated, m authors policies on real client projects. Each project opts in by adding rows.
4. 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_rejected` event 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

- [x] Locked constraints summarised (§0)
- [x] Existing-code grounding (§1)
- [x] Role taxonomy / hierarchy (§2)
- [x] Rule grammar (§3)
- [x] Lifecycle flow + edit allowlist + deadlock + revocation (§4)
- [x] UI surfaces (§5)
- [x] Schema (§6)
- [x] Service-layer integration (§7)
- [x] Audit / chronology (§8)
- [x] RLS / security (§9)
- [x] Migration plan + phasing (§10)
- [x] Trade-offs (§11)
- [x] 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.
