Files
paliad/docs/design-approvals-2026-05-06.md
m 7d1ddb9b84 docs(t-paliad-138): inventor design — dual-control approvals (4-eye)
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.
2026-05-06 14:58:01 +02:00

52 KiB
Raw Permalink Blame History

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_rolestandard | global_admin. Tool-admin gate only.
    • paliad.project_teams.rolelead | 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

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

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

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):

.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

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:

  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:

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_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 13 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 13 (schema + service core + service-layer wiring) on mai/cronus/approvals-impl-1. Then a fresh coder (curie or fritz) does commits 48 (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.