# Multi-Signatory Coordination — Implementation Plan

**Spec:** docs/specs/2026-05-31-multi-signatory-coordination.md  ·  **Slug:** multi-signatory-coordination  ·  **Wave:** 12
**Depends on:** contract-signing-page, contracts-esignature, foundation-auth-rbac

## Goal
Fill the gaps spec 48 (`contracts-esignature`) left open around managing multiple signatories on a contract: a DRAFT-only UI to set signing order (simultaneous vs sequential) via drag-and-drop, per-signatory live status, manual per-signatory reminder/resend/replace actions, and a daily auto-reminder cron. No new tables — this adds two columns to the existing `contract_signatories` table and a small set of routes, a cron, and UI on the existing `/contracts/:id` detail page.

## Architecture
This spec extends, never replaces, the contracts module owned by `contracts-esignature` (plan `docs/plans/tasks/contracts-esignature.md`). It consumes these upstream exports/tables verbatim:

- **Tables:** `contracts` (status enum `DRAFT|SENT|VIEWED|SIGNED|VOIDED`, columns `sent_at`, `signed_at`, `status`), `contract_signatories` (columns `id`, `contract_id`, `name`, `email`, `"order"` — reserved word, quote it; `token`, `token_expires_at`, `viewed_at`, `signed_at`, `signature_data`, `signature_type`, `declined_at`, `decline_reason`), `contract_audit_log`.
- **Helpers from contracts-esignature plan:** `activeSignatoryOrder(signatories)` (lowest `"order"` with no `signed_at`/`declined_at` — the gating primitive this spec builds on), `appendContractAudit(db, {...})`, `sendSignatureRequest(signatory, contract, tenant)` (the signing-invitation email — reused for reminders/resends), `signatoryInputSchema` (Zod `{ name, email }` shape — extended here with `id?` + `order`).
- **Foundation exports:** `authMiddleware`, `requirePermission`, `requireModuleEnabled`, `tenantQuery`, `rateLimit`, `RATE_LIMITER_AUTH`.
- **App layout:** API in `apps/zync-api` (Hono on Workers); the contracts route group already exists as `apps/zync-api/src/routes/contracts.ts`. App UI in `apps/zync-app`; the contract detail page + `useContracts` hook already exist from the contracts plan.

**Data flow — manual reminder:** staff opens `/contracts/:id` (SENT) → clicks "Send reminder" on a signatory row → `POST /api/contracts/:id/signatories/:sigId/reminder` → server checks `reminder_sent_at` 24h gate (DB-level, 429 if too soon) → `sendSignatureRequest` → bumps `reminder_sent_at`/`reminder_count` → `appendContractAudit`.

**Data flow — auto-reminder cron:** CF Cron Trigger (daily 09:00 UTC) → `POST /api/cron/contract-signing-reminders` (guarded by `CRON_SECRET`) → runs the spec's gating SQL (skips signatories whose turn has not arrived, and those reminded < 7 days ago) → `sendSignatureRequest` per row → bumps reminder fields + audit.

**Signing-order ownership:** this spec OWNS the order-gating rules (simultaneous = all `"order"=1`; sequential = distinct orders, only the current signer's link is live; next signatory emailed after the prior signs). The send-time dispatch and the on-sign "notify next" hook physically live in the upstream sign/send handlers (`POST /api/contracts/:id/send` and `POST /api/sign/:token` in `contracts-esignature`); this plan provides the shared `orderedSignatoryDispatch` helper they call so the rule lives in exactly one place, and Task 2 documents the wiring point.

## Tech Stack
- **API:** `apps/zync-api` — Hono routes, Drizzle ORM over Hyperdrive→Neon Postgres, Zod validation, `@zync/db`, `@zync/auth`, `@zync/notifications` (email via `sendSignatureRequest`).
- **Cron:** Cloudflare Cron Trigger declared in `apps/zync-api/wrangler.toml` (`[triggers] crons`), dispatched to an internal `POST /api/cron/contract-signing-reminders` route authed by `CRON_SECRET` (same pattern as `recurring-invoice-generate`).
- **App UI:** `apps/zync-app` — React 19 + Vite, `@zync/ui` components (`Card`, `Button`, `Badge`, `Radio`, `Dialog`, `Input`, `toast`), `@tanstack/react-query` for the signatory hook, `@dnd-kit/core` + `@dnd-kit/sortable` for drag-reorder (same library the contracts create-flow uses for Step-3 reordering — reuse, do not introduce a new dnd lib).
- **Bindings:** all existing — `DB` (Hyperdrive), `RATE_LIMITER_AUTH`, `CRON_SECRET`. No new bindings.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A (schema) | 1 | `packages/db/src/schema/contracts.ts`, `packages/db/migrations/*` | No — blocks all |
| B (domain) | 2 | `apps/zync-api/src/contracts/signing-order.ts` | After A |
| C (API) | 3, 4, 5, 6 | `apps/zync-api/src/routes/contracts.ts` | After B; 3/4/5/6 parallel |
| D (cron) | 7 | `apps/zync-api/src/cron/contract-signing-reminders.ts`, `apps/zync-api/src/index.ts`, `apps/zync-api/wrangler.toml` | After B |
| E (UI) | 8, 9 | `apps/zync-app/src/hooks/useSignatories.ts`, `apps/zync-app/src/features/contracts/SignatoriesPanel.tsx` | After C |

## Tasks

### Task 1: Schema delta — reminder tracking columns on `contract_signatories`
**Blocks:** 2, 3, 5, 7  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/contracts.ts`
- Create: `packages/db/migrations/00XX_signatory_reminders.sql`
**Steps:**
- [ ] Add the two columns to the `contractSignatories` Drizzle table definition (keep `order` quoted via `integer('order')` exactly as upstream).
- [ ] Generate/author the SQL migration with the exact DDL below (idempotent guards via `IF NOT EXISTS`).
- [ ] Confirm `reminder_count` is `INTEGER` (never boolean) and `reminder_sent_at` is `TIMESTAMPTZ` (nullable — no default).
- [ ] Do NOT add any new table; this spec introduces no tables.
**Schema / Interfaces:**
```sql
ALTER TABLE contract_signatories ADD COLUMN IF NOT EXISTS reminder_sent_at TIMESTAMPTZ;
ALTER TABLE contract_signatories ADD COLUMN IF NOT EXISTS reminder_count  INTEGER NOT NULL DEFAULT 0;
```
```ts
// packages/db/src/schema/contracts.ts — additions to existing contractSignatories table
reminderSentAt: timestamp('reminder_sent_at', { withTimezone: true }),
reminderCount:  integer('reminder_count').notNull().default(0),
```
**Acceptance:**
- [ ] Migration applies cleanly on a DB that already has spec-48 `contract_signatories`; re-running is a no-op.
- [ ] Drizzle types expose `reminderSentAt: Date | null` and `reminderCount: number`.

### Task 2: Signing-order domain helper (simultaneous vs sequential dispatch)
**Blocks:** 3, 7  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/contracts/signing-order.ts`
**Steps:**
- [ ] Implement `isSequential(signatories)`: `true` when `"order"` values are not all `1` (i.e. distinct orders present). Simultaneous = every signatory has `order === 1`.
- [ ] Implement `currentTurnOrder(signatories)`: thin re-export/wrapper over upstream `activeSignatoryOrder` — the lowest `"order"` among signatories with `signed_at IS NULL AND declined_at IS NULL`. Do NOT redefine that logic; import it from the contracts-esignature module.
- [ ] Implement `isSignatoryTurn(signatory, signatories)`: returns `true` when simultaneous, OR when `signatory.order === currentTurnOrder(signatories)`. This is the single source of truth for "is this signatory's link active".
- [ ] Implement `recipientsForDispatch(signatories)`: on send/after-each-sign, returns the signatories who should receive an email now — all of them when simultaneous, else only those whose `"order" === currentTurnOrder` and are unsigned/undeclined.
- [ ] Document at the top of this file (comment) the two wiring points owned upstream that must call `recipientsForDispatch`: (a) `POST /api/contracts/:id/send` initial dispatch, (b) `POST /api/sign/:token` after recording a signature, to email the now-current next signatory. This plan provides the helper; the upstream handlers import it.
- [ ] Validate signing-order uniqueness at write time (Task 3): orders must be unique per contract OR all equal to `1`.
**Schema / Interfaces:**
```ts
import { activeSignatoryOrder } from './audit-and-order'; // contracts-esignature export

export interface SignatoryOrderRow {
  id: string; order: number;
  signedAt: Date | null; declinedAt: Date | null;
}
export function isSequential(s: SignatoryOrderRow[]): boolean;
export function currentTurnOrder(s: SignatoryOrderRow[]): number; // wraps activeSignatoryOrder
export function isSignatoryTurn(sig: SignatoryOrderRow, all: SignatoryOrderRow[]): boolean;
export function recipientsForDispatch(all: SignatoryOrderRow[]): SignatoryOrderRow[];
export function assertOrdersValid(orders: number[]): void; // throws 422 unless unique-per-contract or all === 1
```
**Acceptance:**
- [ ] All-`1` orders → `isSequential === false`, `recipientsForDispatch` returns every unsigned signatory.
- [ ] Orders `[1,2,3]` with #1 signed → `currentTurnOrder === 2`, `recipientsForDispatch` returns only the order-2 row.
- [ ] `assertOrdersValid([1,1,2])` throws 422 (duplicate non-simultaneous order).

### Task 3: `GET` + bulk `PATCH /api/contracts/:id/signatories`
**Blocks:** 8, 9  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/contracts.ts`
**Steps:**
- [ ] `GET /api/contracts/:id/signatories` (`requirePermission('contracts:read')`): tenant-scope the parent contract via `tenantQuery`; return signatories ordered by `"order"` then `name`, each serialized with derived `status` (see serializer below), `reminderSentAt`, `reminderCount`, and a `waitingFor` name when sequential and not their turn.
- [ ] `PATCH /api/contracts/:id/signatories` (`requirePermission('contracts:write')`): allowed ONLY when `contracts.status === 'DRAFT'` — return 422 otherwise (order is locked after SENT; replacing post-SENT goes through Task 5). Body `{ signatories: [{ id?, name, email, order }] }`, max 3 rows.
- [ ] Validate body with `bulkSignatoriesSchema`; call `assertOrdersValid(orders)`; reject > 3 signatories (422); enforce `UNIQUE(contract_id, email)` (surface 422 on collision).
- [ ] Upsert in a transaction: update rows with `id`, insert rows without `id` (mint nothing token-wise in DRAFT — tokens are minted at send time per spec 48), delete signatory rows absent from the payload.
- [ ] `appendContractAudit(tx, { contractId, event: 'signatories_updated', actorType: 'user', actorId, actorName, metadata: { orders } })`.
- [ ] Implement `serializeSignatory(sig, all)` returning the derived status string per the matrix below.
**Schema / Interfaces:**
```ts
export const bulkSignatoriesSchema = z.object({
  signatories: z.array(z.object({
    id: z.string().uuid().optional(),
    name: z.string().min(1).max(200),
    email: z.string().email().max(320),
    order: z.number().int().min(1).max(3),
  })).min(1).max(3),
});

export type SignatoryStatus = 'signed' | 'viewed' | 'sent' | 'waiting' | 'declined';
// Derivation precedence (spec §"Signatory Status"):
//   declined_at  set        -> 'declined'
//   signed_at    set        -> 'signed'
//   not this signatory's turn (sequential) -> 'waiting'
//   viewed_at    set        -> 'viewed'
//   else (email dispatched, unviewed)      -> 'sent'
export function serializeSignatory(sig, all): {
  id: string; name: string; email: string; order: number;
  status: SignatoryStatus;
  signedAt: string | null; viewedAt: string | null; declinedAt: string | null;
  reminderSentAt: string | null; reminderCount: number;
  waitingFor: string | null; // name of the signatory currently blocking, when status==='waiting'
};
```
**Acceptance:**
- [ ] `PATCH` on a SENT contract returns 422 and changes nothing.
- [ ] `GET` returns signatories ordered by `"order"`; a sequential order-3 row with order-2 unsigned shows `status: 'waiting'`, `waitingFor: '<order-2 name>'`.
- [ ] Overall progress (X of Y signed) is computable from the returned array (count of `status==='signed'`).

### Task 4: `POST /api/contracts/:id/signatories/:sigId/reminder` (manual, 24h-gated)
**Blocks:** 9  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/contracts.ts`
**Steps:**
- [ ] Route `requirePermission('contracts:write')`; tenant-scope contract; load signatory by `:sigId` within the contract (404 if mismatch).
- [ ] Reject (409/422) if the signatory has already `signed_at` or `declined_at` (no reminding a finished signatory).
- [ ] **App-level rate limit (NOT a CF RateLimiter binding):** if `reminder_sent_at IS NOT NULL AND reminder_sent_at > now() - interval '24 hours'` → return **429** with a `retryAfter` hint. This is a DB-column check; do not reach for `RATE_LIMITER_*`.
- [ ] Send the reminder via upstream `sendSignatureRequest(signatory, contract, tenant)` (same invitation email, no new token).
- [ ] In a transaction: `SET reminder_sent_at = now(), reminder_count = reminder_count + 1`; `appendContractAudit(tx, { event: 'reminder_sent', actorType: 'user', metadata: { signatoryId } })`.
**Schema / Interfaces:**
```ts
// POST /api/contracts/:id/signatories/:sigId/reminder -> 200 { reminderSentAt, reminderCount } | 429 { retryAfter }
// Gate SQL fragment:
//   WHERE reminder_sent_at IS NULL OR reminder_sent_at < now() - interval '24 hours'
```
**Acceptance:**
- [ ] Two reminders within 24h: first 200, second 429 (no email sent, `reminder_count` unchanged on the 429).
- [ ] `reminder_count` increments by exactly 1 per successful send; `reminder_sent_at` updated to now().
- [ ] Reminder against a signed/declined signatory returns 4xx and sends nothing.

### Task 5: `POST .../resend` (new token) + `PATCH .../:sigId` (replace name/email)
**Blocks:** 9  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/contracts.ts`
**Steps:**
- [ ] `POST /api/contracts/:id/signatories/:sigId/resend` — `requirePermission('contracts:write')` **plus** an OWNER/ADMIN role guard. (Spec conflict: prose §"Resend Signing Link" says "OWNER/ADMIN only"; the API table says "contracts:write". Reconcile to the stricter reading: require `contracts:write` AND tenant role ∈ {OWNER, ADMIN}; a MEMBER with `contracts:write` is rejected 403.)
- [ ] Resend regenerates token: new `token` = fresh UUID v4, `token_expires_at = now() + interval '30 days'`; this invalidates the old token (old link 404/410s on next access). Reject if signatory already `signed_at` (422). Then `sendSignatureRequest`.
- [ ] `appendContractAudit(tx, { event: 'link_resent', actorType: 'user', metadata: { signatoryId } })`.
- [ ] **Route-collision note:** contracts-esignature already defines `POST /api/contracts/:id/resend/:signatoryId`. This spec's `/signatories/:sigId/resend` (token regen) and `/signatories/:sigId/reminder` (no token regen) SUPERSEDE that older route. Mark the legacy `/resend/:signatoryId` as deprecated in `contracts.ts` (keep it routing to the reminder handler for backward compat, or remove if no consumer) — do not build two live regeneration paths.
- [ ] `PATCH /api/contracts/:id/signatories/:sigId` — `requirePermission('contracts:write')`; allowed only while `contracts.status === 'SENT'` (or `VIEWED`) AND the signatory has not signed (else 422). Body `{ name?, email? }` validated by `replaceSignatorySchema`. Enforce `UNIQUE(contract_id, email)`.
- [ ] On replace: update name/email, **regenerate token + `token_expires_at`** (new recipient must get a fresh link), `sendSignatureRequest`, and `appendContractAudit(tx, { event: 'signatory_replaced', metadata: { signatoryId, oldEmail, newEmail } })`.
**Schema / Interfaces:**
```ts
export const replaceSignatorySchema = z.object({
  name:  z.string().min(1).max(200).optional(),
  email: z.string().email().max(320).optional(),
}).refine(v => v.name !== undefined || v.email !== undefined, 'at least one field required');
// resend / replace both: token = crypto.randomUUID(); token_expires_at = now() + 30d
```
**Acceptance:**
- [ ] After resend, the previous token resolves as invalid; the new token resolves to the same signatory.
- [ ] MEMBER (has `contracts:write`, role MEMBER) calling resend → 403; OWNER/ADMIN → 200.
- [ ] `PATCH` replace on a SENT contract for an unsigned signatory updates email, mints a new token, emails the new address; replace on a signed signatory → 422.
- [ ] Every resend/replace writes a `contract_audit_log` row.

### Task 6: Wire order-gated dispatch into the upstream send + sign handlers
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/contracts.ts` (send handler), `apps/zync-api/src/routes/sign.ts` (public sign handler)
**Steps:**
- [ ] In `POST /api/contracts/:id/send` (upstream), replace the "email all signatories" step with `recipientsForDispatch(signatories)` from Task 2 so simultaneous sends to all and sequential sends only to order-1.
- [ ] In `POST /api/sign/:token` (upstream public handler), after recording a signature and before/alongside `completeIfAllSigned`, compute `recipientsForDispatch` over the updated set and email the newly-current next signatory (no-op when all signed or simultaneous). Do NOT duplicate `completeIfAllSigned` — that completion logic is owned by contracts-esignature; only add the next-turn notification.
- [ ] Ensure the `/sign/:token` GET "Waiting for {name}" state uses `isSignatoryTurn` so the page and the dispatch logic agree on whose turn it is.
**Acceptance:**
- [ ] Sending a sequential `[1,2,3]` contract emails only signatory 1; after 1 signs, only signatory 2 is emailed; etc.
- [ ] Sending a simultaneous (`all order=1`) contract emails all signatories at once.
- [ ] Completion still fires exactly once via the upstream `completeIfAllSigned` (no double-trigger).

### Task 7: Auto-reminder cron `contract-signing-reminders`
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/cron/contract-signing-reminders.ts`
- Modify: `apps/zync-api/src/index.ts`, `apps/zync-api/wrangler.toml`
**Steps:**
- [ ] Declare the CF Cron Trigger in `wrangler.toml`: `[triggers] crons = ["0 9 * * *"]` (daily 09:00 UTC = 10:00 Israel; document the DST note that "10:00 Israel" holds in winter, 12:00 in summer — UTC is authoritative).
- [ ] Register `POST /api/cron/contract-signing-reminders`, authed by `CRON_SECRET` (Bearer header), same pattern as `recurring-invoice-generate`; the Worker `scheduled()` handler in `index.ts` dispatches to it.
- [ ] Run the verbatim selection SQL below to find due signatories (unsigned, undeclined, parent contract `SENT`/`VIEWED`, last reminder null or > 7 days ago, AND it is currently their turn per the sequential subquery).
- [ ] For each due row: `sendSignatureRequest`, then `UPDATE contract_signatories SET reminder_sent_at = now(), reminder_count = reminder_count + 1`, then `appendContractAudit(db, { event: 'reminder_sent', actorType: 'system', metadata: { signatoryId, source: 'cron' } })`. Process in batches; tolerate per-row email failure (log, continue).
- [ ] Note: the cron's own cadence is 7 days (`> interval '7 days'`), independent of the manual 24h gate in Task 4; the two share the `reminder_sent_at` column.
**Schema / Interfaces:**
```sql
SELECT cs.id
FROM contract_signatories cs
JOIN contracts c ON c.id = cs.contract_id
WHERE c.status IN ('SENT', 'VIEWED')
  AND cs.signed_at IS NULL
  AND cs.declined_at IS NULL
  AND (cs.reminder_sent_at IS NULL OR cs.reminder_sent_at < now() - interval '7 days')
  AND (
    c.id NOT IN (
      SELECT contract_id FROM contract_signatories
      WHERE signed_at IS NULL AND "order" < cs."order"
    )
  );
```
**Acceptance:**
- [ ] A sequential contract with order-1 unsigned does NOT reminder order-2/3 (turn-gate subquery excludes them).
- [ ] A signatory reminded 3 days ago is skipped; one reminded 8 days ago (or never) is reminded.
- [ ] Each cron send writes a `system` actor row to `contract_audit_log` and bumps `reminder_count`.
- [ ] Endpoint returns 401 without a valid `CRON_SECRET`.

### Task 8: `useSignatories` data hook (app)
**Blocks:** 9  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/hooks/useSignatories.ts`
**Steps:**
- [ ] `useSignatories(contractId)` — react-query `useQuery` GET `/api/contracts/:id/signatories`; returns the serialized array (status, reminder counts, waitingFor).
- [ ] Mutations, each invalidating the query and the parent contract query, with `toast` on success/error: `useUpdateSignatories` (bulk PATCH), `useSendReminder` (POST reminder — surface 429 as a "wait 24h" toast), `useResendLink` (POST resend), `useReplaceSignatory` (PATCH :sigId).
**Schema / Interfaces:**
```ts
export function useSignatories(contractId: string): UseQueryResult<SerializedSignatory[]>;
export function useUpdateSignatories(contractId: string): UseMutationResult<SerializedSignatory[], Error, { signatories: BulkSignatoryInput[] }>;
export function useSendReminder(contractId: string): UseMutationResult<{ reminderSentAt: string; reminderCount: number }, Error, { signatoryId: string }>;   // handles 429
export function useResendLink(contractId: string): UseMutationResult<void, Error, { signatoryId: string }>;
export function useReplaceSignatory(contractId: string): UseMutationResult<void, Error, { signatoryId: string; name?: string; email?: string }>;
```
**Acceptance:**
- [ ] Hook returns live status per signatory; mutations refresh the panel without a full reload.
- [ ] A 429 from reminder surfaces a user-readable "already reminded in the last 24 hours" toast, not a raw error.

### Task 9: `SignatoriesPanel` UI on `/contracts/:id`
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/contracts/SignatoriesPanel.tsx`
- Modify: contract detail page in `apps/zync-app/src/features/contracts/` (mount the panel in the right column)
**Steps:**
- [ ] **DRAFT state:** "Signatories" `Card` with `[+ Add signatory]` (max 3), an order mode `Radio` group ("Simultaneously" = all `order=1` / "In sequence" = distinct orders), and a drag-to-reorder list using `@dnd-kit/sortable` (same lib as the contracts create-flow Step 3). Editing a row inline (name/email). On change, debounce-call `useUpdateSignatories`. Order is locked (drag disabled, mode `Radio` disabled) once status ≥ SENT — show a hint "Locked after sending; void & re-send to change order".
- [ ] **SENT/VIEWED/SIGNED state:** read-only ordered list; each row shows the order badge (①②③), name/email, status indicator + timestamp, and an "Overall: X of Y signed" summary. Map status → label/icon: signed ✅, viewed 👁, sent 📬, waiting ⏳ (with "waiting for {waitingFor}"), declined ❌.
- [ ] Per-row actions when SENT/VIEWED and the row is unsigned/undeclined: `[Send reminder]` (→ `useSendReminder`; disabled with tooltip if `reminderSentAt` < 24h ago), `[Resend link]` (→ `useResendLink`; only render for OWNER/ADMIN), `[Replace]` (→ `Dialog` with name/email → `useReplaceSignatory`).
- [ ] **Accessibility:** reorder list is keyboard-operable (dnd-kit `KeyboardSensor`); each draggable row has `aria-roledescription="sortable"` and announces position; status icons have text equivalents (`aria-label`), never icon-only; the order-mode toggle is a labelled `radiogroup`. Honor `prefers-reduced-motion` — disable drag-transition animation when set.
- [ ] **RTL:** order arrows and the ①→②→③ sequence visual must flip under `dir="rtl"` (Hebrew); use logical CSS properties (`margin-inline-*`), not left/right.
**Acceptance:**
- [ ] In DRAFT, dragging rows reorders and persists `order`; switching to "Simultaneously" sets all `order=1`.
- [ ] After SENT, reorder controls are disabled and the panel shows live per-signatory status + "X of Y signed".
- [ ] "Send reminder" is disabled within 24h of the last reminder; "Resend link" is hidden for non-OWNER/ADMIN users.
- [ ] Panel is fully keyboard-navigable, status conveyed by text (not color/icon alone), and mirrors correctly under RTL.
