A3 — admin/approval-policies 2-control flip:
Each cell becomes [✓] requires_approval checkbox + role select + clear
button. The "none" option in the role dropdown is gone — the checkbox
replaces it. Role select is greyed when the checkbox is off (gate
closed). Clear button explicitly drops the cell back to inheritance.
Project matrix surfaces inherited "no approval" state with its own
attribution chip ("Geerbt · keine Genehmigung") so admins can tell a
silently-inherited off-state from a never-authored cell.
PUT /api/.../approval-policies/{entity}/{lifecycle} accepts the new
shape `{requires_approval: bool, min_role: string|null}` while still
honouring the legacy `{required_role: "..."}` body during the M1
dual-read window (decodePolicyBody routes to UpsertProjectPolicySplit
vs UpsertProjectPolicy accordingly).
C+E — Pending-approval badge + Withdraw button:
deadlines-detail + appointments-detail surface a "Wartet auf
Genehmigung" badge when approval_status='pending'. Hover-tooltip
carries requested_at + required_role + requester_name. Action
controls (Complete, Edit, Delete) freeze while pending — caller
would get a 409 anyway, no point letting them try.
Withdraw button visible only to the requester (me.id ===
pending_request.requested_by). Click → POST /api/approval-requests/
{id}/revoke (existing endpoint, no new server route). On success,
the entity flips back to approval_status='approved' and the page
re-renders with normal controls.
Complete button now handles 409 from the server gracefully:
surfaces the new mapApprovalError body's `message` instead of
silently disabling itself.
D — /inbox "Meine Anfragen" visibility hardening:
Three defence-in-depth fixes for the "tab shows empty" report:
1. handlers force `[]` (not Go-nil → JSON null) on every inbox
endpoint so the frontend never trips on `rows.length` of null.
2. parseInboxFilter validates ?status= against an allowlist
(pending|approved|rejected|revoked|superseded). Anything else
is silently dropped — a stray ?status=foo from a stale
frontend build can no longer shadow rows out of the result.
entity_type filter same treatment (deadline|appointment).
3. Frontend inbox.ts coerces null body → [] so older / cached
builds talking to the new server still don't crash.
Test coverage: TestParseInboxFilter_DropsUnknownStatus +
TestApprovalService_ListSubmittedByUser_PendingVisible (live-DB,
skipped without TEST_DATABASE_URL).
Build clean: bun build OK, go test ./... OK.
Defers: M2 (drop required_role column) — only fires once all
in-tree writers are confirmed off the legacy column path.