# Comprehensive Audit — 2026-05-06

Conducted directly after the smart-archive / hard-delete / bulk-wizard /
audit-overhaul / synthetic-seed batches landed (commits `d07f1ed`
through `9890d06`). Prior comprehensive audit:
`docs/audit-comprehensive-2026-05-05.md`.

Findings are sorted by severity. Each has a concrete file:line, a
scenario, and a fix recommendation.

---

## CRITICAL

### C1. 5 of 10 BullMQ workers are never imported (production + dev)

**Files:** `src/worker.ts:13-17`, `src/server.ts:72-76`

`src/worker.ts` (production) and `src/server.ts` (dev fallback) both
import only:

- `emailWorker`
- `documentsWorker`
- `notificationsWorker`
- `importWorker`
- `exportWorker`

**Missing:** `aiWorker`, `bulkWorker`, `maintenanceWorker`, `reportsWorker`, `webhooksWorker`.

Because BullMQ workers are constructed at the top of each worker
module and only "start" when the module is imported, never importing
them means:

- **Webhooks never deliver.** `webhooksWorker` is what processes the
  `webhooks` queue; the admin "Replay" button we just shipped enqueues
  jobs that pile up in `pending` forever.
- **All maintenance crons silently no-op.** `maintenanceWorker` handles
  `database-backup`, `backup-cleanup`, `session-cleanup`,
  `currency-refresh`, `gdpr-export-cleanup`, `ai-usage-retention`,
  `error-events-retention`, `website-submissions-retention`,
  `alerts-evaluate`, `analytics-refresh`, `calendar-sync`,
  `temp-file-cleanup`, `form-expiry-check` — none run.
- **Scheduled reports never generate.** `reportsWorker` handles
  `report-scheduler` (every minute).
- **Bulk jobs never process** (the synchronous bulk endpoints work, but
  any deferred-bulk path is dead).
- **AI usage features never run.**

**Impact:** Production CRM has been silently shedding webhook
deliveries, never running retention/cleanup, never sending scheduled
reports.

**Fix:**

```ts
// Append to src/worker.ts AND the inline section of src/server.ts:
import { aiWorker } from '@/lib/queue/workers/ai';
import { bulkWorker } from '@/lib/queue/workers/bulk';
import { maintenanceWorker } from '@/lib/queue/workers/maintenance';
import { reportsWorker } from '@/lib/queue/workers/reports';
import { webhooksWorker } from '@/lib/queue/workers/webhooks';

const workers = [
  emailWorker,
  documentsWorker,
  notificationsWorker,
  importWorker,
  exportWorker,
  aiWorker,
  bulkWorker,
  maintenanceWorker,
  reportsWorker,
  webhooksWorker,
];
```

After fix, run `pnpm dev` and watch `/admin/webhooks/{id}` deliveries
go from `pending` → `success` to confirm.

---

## HIGH

### H1. Hard-delete request endpoints have zero rate limiting

**Files:**

- `src/app/api/v1/clients/[id]/hard-delete-request/route.ts:1-37`
- `src/app/api/v1/clients/bulk-hard-delete-request/route.ts:1-32`

Each call writes a fresh code to Redis and emails it to the operator's
address. No `withRateLimit(...)`. An attacker who has compromised an
admin account (or even just the new `permanently_delete_clients`
permission) can:

1. Email-bomb the admin's own inbox (every request → email).
2. Probe whether arbitrary client IDs exist (200 + `sentToMaskedEmail`
   vs 404 `client not found` is a UID oracle).
3. Burn SMTP quota.

**Fix:** add `withRateLimit('auth', ...)` or a new dedicated bucket
(e.g. 5 per hour per user). Pattern is already in
`src/app/api/v1/clients/[id]/gdpr-export/route.ts`.

### H2. Audit-page view fires on every paginated reload (log spam)

**File:** `src/app/api/v1/admin/audit/route.ts:48-72`

I added a "watch the watchers" `view` audit row for first-page audit
fetches. That's the right idea, but the page also re-fires the request
on every filter change (severity, source, action, date range, search).
A diligent admin filtering through the inspector for an investigation
will write dozens of `view` audit rows per minute — making it harder to
find the actual events they're looking for.

**Fix:** dedupe in Redis with a 60-second per-user TTL key, only emit
if the key didn't exist. Or only fire when no filters are active.

### H3. Hard-delete error messages distinguish "no code" vs "wrong code"

**File:** `src/lib/services/client-hard-delete.service.ts:166-174`

```ts
if (!stored) throw new ValidationError('Confirmation code expired or not requested');
if (!safeEqualStr(stored, args.code.trim())) {
  throw new ValidationError('Confirmation code is incorrect');
}
```

The two messages let an attacker distinguish "you've never requested a
code" (so spam the request endpoint to open the window) from "wrong
code" (so brute-force more codes). 4-digit space is only 10,000 — with
distinguishable feedback an attacker can confirm code validity in
≤5,000 attempts on average.

**Fix:** collapse to a single `'Invalid or expired code'` message; the
operator already has the email open and knows what they typed.

### H4. Synthetic seed leaves `super_admin` linked-port-roles empty

**File:** `src/lib/db/seed-bootstrap.ts:147-160`

The bootstrap creates the `userProfiles` row with
`isSuperAdmin: true` for `super-admin-matt-portnimara`, but doesn't
create `userPortRoles` rows. The actual real `user` rows (admin@,
agent@, viewer@) are only created via the Playwright global-setup.
Anyone running `pnpm db:seed:synthetic` then `pnpm dev` and trying to
log in via the UI hits an unauthenticated state until they also run
playwright setup or sign up via better-auth manually.

**Fix:** either document this in `CLAUDE.md` Quick Reference, or add a
`pnpm db:seed:dev-users` companion script that signs up the three
test users + links roles. Today's synthetic-seed flow felt clean
because the playwright setup was still applied; in a fresh clone it
will surprise.

### H5. Documenso bad-secret 200 response is correct, but enables enum oracle

**File:** `src/app/api/webhooks/documenso/route.ts:67-86`

The route returns `200 ok=false error=Invalid secret` for a wrong
secret. That's webhook best-practice (don't leak signal to attackers),
but combined with the new audit row that captures
`metadata.providedLen`, an attacker can probe secret-length over time
without being detected (just a "warning" row per attempt). On an admin
inspector with 1000s of rows, a slow-rate probe is invisible.

**Fix:** add per-IP rate limit (5/min) to `/api/webhooks/documenso/`
when secret check fails. Don't block real Documenso traffic — it
shouldn't fail the secret check.

### H6. The audit-log inspector page itself isn't backed by a real "view" gate beyond `admin.view_audit_log`

**File:** `src/app/api/v1/admin/audit/route.ts:31`

Audit log has the most sensitive cross-cutting data in the system
(every login attempt with attempted email, every secret-regenerate,
every hard-delete). It's gated only by `admin.view_audit_log`. The
seed grants this to `director` AND `super_admin`. Consider:

- making the page super-admin-only for production, OR
- adding a secondary confirmation when viewing rows that contain
  attempted emails / IP ranges (PII).

**Fix:** change `withPermission('admin', 'view_audit_log', ...)` to
add `if (!ctx.isSuperAdmin) check sensitive_audit_view`. Or accept
the current model but document it in the role docs.

### H7. Three "coming soon" stubs in production UI

**Files:**

- `src/components/clients/client-tabs.tsx:276` — "File attachments coming soon."
- `src/components/clients/client-reservations-tab.tsx:41` — "History is coming soon."
- `src/components/berths/berth-tabs.tsx:327` — "{label} coming soon"

Visible to every user on every client / berth detail page. Either ship
the feature or hide the tab.

**Fix:** for `client-tabs.tsx` line 276 (Files), the `files` table
already exists and supports clientId — ship a list view.
For `berth-tabs.tsx` line 327 — find the calling tab labels and
either implement or remove from the tabs array.
For `client-reservations-tab.tsx` line 41 — query past reservations
when the user toggles a "show history" filter.

---

## MEDIUM

### M1. `attachWorkerAudit` recurring job names list duplicates scheduler.ts (drift risk)

**File:** `src/lib/queue/audit-helpers.ts:23-46`

The 20 recurring job names are hardcoded in the audit helper; the
scheduler also has its own list. If someone adds a new cron without
updating both, the cron_run audit row never fires for that job.

**Fix:** export the list from `scheduler.ts` and import it in
`audit-helpers.ts`. Single source of truth.

### M2. `client-merge-log.surviving_client_id` deleted by hard-delete (history loss)

**File:** `src/lib/services/client-hard-delete.service.ts:200-202`

Hard-delete drops every `client_merge_log` row whose surviving id
matches. Those rows are the audit trail of WHO was merged INTO this
client. Once deleted, you've lost evidence of the prior merge.

**Fix:** replace `delete` with a column nullification, or move the row
to a `client_merge_log_archive` table. Audit trail per GDPR Article 5
should outlive the data.

### M3. Bulk hard-delete loops one-shot codes through Redis (5x writes)

**File:** `src/lib/services/client-hard-delete.service.ts:382-396`

For a 100-client bulk delete, the function writes 100 single-client
codes to Redis just to satisfy `hardDeleteClient`'s expectation. Each
write is a round-trip; on a Redis hiccup mid-loop, you can end up
with a half-deleted batch.

**Fix:** refactor `hardDeleteClient` so the inner deletion can be called
without the per-client code check (extract `_doHardDelete()` private
helper used by both single and bulk paths). Keeps Redis clean.

### M4. Smart-restore wizard has dead reversal applier for `berth_released`

**File:** `src/lib/services/client-restore.service.ts:360-372`

The `applyReversal` switch case for `'berth_released'` does nothing —
it just leaves the berth available. The wizard surfaces this as
"auto-reversible" if the berth is still free, but the actual restore
doesn't re-attach the berth to any interest. Operator clicks Restore
expecting their berth back; nothing changes on the berth.

**Fix:** either (a) at archive time, persist the original interestId
in the decision metadata so we can re-link, or (b) update the wizard
copy to make clear the berth is "available for re-attach" rather than
"will be re-attached."

### M5. Several services use `void createAuditLog(...)` without `.catch()`

**Files:** widespread; e.g. `src/lib/services/client-hard-delete.service.ts:127-136, 230-240`,
`src/lib/services/portal-auth.service.ts:269-276`

`createAuditLog` is documented as never-throwing (catches internally),
but defense-in-depth: a `void` Promise that throws produces an
unhandled rejection event. Most paths are fine because the helper
catches; if anyone refactors `createAuditLog` and removes the catch,
this becomes a process-killer.

**Fix:** convention rule: every `void someAsync()` must have a `.catch()`.
Codify with a custom ESLint rule, or wrap at call sites:
`void createAuditLog({...}).catch(() => undefined);`

### M6. Hard-delete audit metadata leaks client `fullName`

**File:** `src/lib/services/client-hard-delete.service.ts:241-247`

After the hard-delete the audit row carries
`metadata: { fullName: client.fullName }`. The client record itself is
gone but their name lives on in the audit log. For a GDPR data subject
who exercised their right-to-erasure, this is technically a retention
of personal data in audit history. Not necessarily wrong (audit logs
have a legitimate-interest basis), but should be conscious.

**Fix:** decide policy: either (a) keep as-is and document, (b) replace
with a hash of the name, or (c) substitute a tombstone identifier.

### M7. Webhook delivery DLQ admin-replay can re-trigger downstream side-effects

**File:** `src/lib/services/webhooks.service.ts:282-326`

Replaying a successful webhook (operator presses Replay on a delivery
that already had `status: 'success'`) re-fires the same payload to the
recipient. If the recipient's idempotency check is weak, you've just
caused a duplicate. The replay payload includes `retried_from` /
`retried_at` markers, which is good — but most recipients won't honor
them.

**Fix:** disable the Replay button when `status === 'success'`. The UI
already gates on `'failed' || 'dead_letter'` — verify it stays that
way (`webhook-delivery-log.tsx:118-131` looks correct; double-check
no regressions).

### M8. `audit_logs` table has no DELETE permission gate

**Files:** schema and routes

There's no admin endpoint to delete audit rows (good). But there's no
DB-level guard either. A super_admin who runs `db:reset` wipes audit
history. Audit retention should be enforced at the schema level so
even a misconfigured operator can't blow away the trail.

**Fix:** create a `audit_logs_no_delete_role` postgres role that lacks
DELETE on the table; document that the app's DB user should not have
DELETE on `audit_logs` in production deployments.

### M9. Documenso void worker uses dynamic import every time

**File:** `src/lib/queue/workers/documents.ts:25`

```ts
const { voidDocument } = await import('@/lib/services/documenso-client');
```

Dynamic import inside a hot per-job path is fine the first time but
slows every subsequent call slightly. Move to top-of-file import
unless there's a deliberate reason (circular dep?).

**Fix:** test moving to top-level import; if it works (no circular
deps), keep it there.

### M10. Bulk archive wizard "blocked" reason copy truncates at first line

**File:** `src/components/clients/bulk-archive-wizard.tsx:153-163`

The wizard shows `b.blockers[0]` for blocked clients. If the dossier
has multiple blockers, only the first is shown. Operators may fix the
first one, retry, and discover a second.

**Fix:** show all blockers (joined with `·`) or a "+N more" badge
with click-to-expand.

---

## LOW

### L1. `next-in-line-notify.service.ts` could double-fire on archive retry

**File:** `src/app/api/v1/clients/[id]/archive/route.ts:114-135`

If the smart-archive request succeeds at the DB transaction level but
the response upload-side fails (network blip, browser closes), the
operator may retry. Each retry re-fires the next-in-line notification
to all sales recipients. The `dedupeKey: berth-released:{berthId}`
inside the notification helper deduplicates within a cooldown window —
so this is mitigated, but worth verifying the cooldown is set and
not 0.

### L2. `interests.berth_id` reference in `seed-data.ts` (legacy seed)

**File:** `src/lib/db/seed-data.ts:973`

The realistic seed inserts `berthId: ...` on the interests table. Per
`CLAUDE.md`, that column was dropped in migration 0029 and replaced
with `interest_berths` junction. The synthetic seed uses the junction
correctly. The realistic seed will FAIL at insert time if anyone
tries to run it on a freshly-migrated DB.

**Fix:** rewrite `seed-data.ts:969-982` to insert into `interests`
without `berthId`, then insert the junction rows separately (mirror
the synthetic seed's pattern).

### L3. Audit log entry for failed login uses `entityId = attemptedEmail` (unbounded)

**File:** `src/app/api/auth/[...all]/route.ts:53-68`

If the entityId is very long (a 500-char "email"), it goes into the
DB column. The column is `text` (unbounded) so no DB error, but FTS
search-text may bloat.

**Fix:** truncate attempted email to 256 chars before using as
entityId.

### L4. The "watch the watchers" audit fires for filtered queries too

**File:** `src/app/api/v1/admin/audit/route.ts:48-72`

(See H2 above for the page-spam variant.) Even on a single search,
an audit row containing the search term is written. If the search
term itself is sensitive (e.g. an admin searches for a specific
client's name in audit logs), it's now in the audit log of audit-log
viewing. Acceptable but worth documenting.

### L5. Import worker is a stub

**File:** `src/lib/queue/workers/import.ts:13`

`// TODO(L2): implement import job handlers` — the worker is wired
into the queue and registered, but does nothing. If anyone enqueues
an `import:*` job, it returns immediately. Either ship the feature
or remove the queue.

### L6. `interest-form.tsx` two TODOs about company-yacht filter + add-yacht inline

**File:** `src/components/interests/interest-form.tsx:332-333`

Real product gaps. When creating an interest for a client who's a
member of a company, you can't pick a yacht owned by that company.
And there's no inline "Add yacht" shortcut in the form.

### L7. `berth-spec-template.ts` defaults to `'Price: TBD'` when price is null

**File:** `src/lib/pdf/templates/berth-spec-template.ts:128`

Generated berth-spec PDFs say "Price: TBD" for any berth without a
price. Cosmetic — verify whether sales considers this an acceptable
fallback or wants to suppress the line entirely.

---

## Things checked and found OK (so we don't re-audit)

- Tenant isolation on hard-delete (`portId` filter on every query and
  inside the tx).
- `withPermission` gates on every new route (bulk-archive-preflight,
  hard-delete-_, bulk-hard-delete-_, redeliver).
- Audit log: no public DELETE endpoint, no PATCH endpoint.
- Sidebar nav properly gates marina sections from `residential_partner`
  via `hasMarinaAccess`.
- Auth wrapper rebuilds the request body correctly so the upstream
  better-auth handler can re-read it (no body-already-consumed bug).
- Webhook outbound SSRF guard with DNS rebinding protection still
  intact.
- 1175/1175 vitest suite passing as of last run.

---

## Recommended fix order (ROUND 1 + 2 combined — see below for Round 2)

See **"Triage list" at the end** of this document — combined ranking
across both audit rounds.

---

## Round 2 — focused agents (added 2026-05-06 evening)

After the original synthesis above, four scoped agents (smaller blast
radius, hard finding caps) successfully audited their domains and
produced dedicated docs. Findings are linked here with `R2-`-prefixed
IDs. Detail in:

- [audit-reliability-2026-05-06.md](audit-reliability-2026-05-06.md) — 11 findings
- [audit-frontend-2026-05-06.md](audit-frontend-2026-05-06.md) — 12 findings
- [audit-permissions-2026-05-06.md](audit-permissions-2026-05-06.md) — 9 findings
- [audit-missing-features-2026-05-06.md](audit-missing-features-2026-05-06.md) — 12 findings

### Round 2 — CRITICAL

**R2-C1. Bulk archive discards post-commit side effects** ([reliability C1](audit-reliability-2026-05-06.md))

- File: `src/app/api/v1/clients/bulk/route.ts:68-134`
- The bulk wizard's `runBulk` callback discards the return value from
  `archiveClientWithDecisions`. **Documenso envelopes marked
  `void_documenso` are never queued for void; "next-in-line" sales
  notifications never fire**. The CRM ends up showing `documents.status='cancelled'`
  while the live envelope is still out for signature — a signer can
  legally complete a doc the CRM thinks is voided.
- Same severity tier as the original C1 (worker-imports).

**R2-C2. Frontend: Restore icon hovers destructive-red on archived clients** ([frontend C1](audit-frontend-2026-05-06.md))

- File: `src/components/clients/client-detail-header.tsx:174-186`
- Conditional `hover:text-destructive` is overridden by an unconditional
  `hover:text-foreground` earlier in the class string. Result: the
  Restore button on archived clients hovers blood-red, signalling
  "destructive" on a fully reversible action. Users hesitate to click.
  Promoted to "critical UX" because it's directly misleading on every
  archived client view.

### Round 2 — HIGH

**R2-H1. Smart-restore wizard's `berth_released` reversal is a no-op but the audit log claims success**
([reliability H1](audit-reliability-2026-05-06.md))

- File: `src/lib/services/client-restore.service.ts:359-372`
- Already noted as M4 in the original synthesis. Round-2 reliability
  agent escalated to HIGH because the wizard counter increments and
  the audit log records "1 auto-reversed" — operator believes the berth
  was re-attached when nothing happened. Same fix path: persist the
  original `interestId` in the decision detail and re-link on restore.

**R2-H2. Smart-archive berth status update has TOCTOU race**
([reliability H2](audit-reliability-2026-05-06.md))

- File: `src/lib/services/client-archive.service.ts:191-207`
- Berth row read outside tx, mutated inside tx without `for update`
  lock. Concurrent archive + sale of the same berth can race: the
  archive flow flips a freshly-sold berth back to `available`. Add
  `select … for update` on `berths` before the status flip.

**R2-H3. Bulk archive can pick the wrong interest for berth release**
([reliability H3](audit-reliability-2026-05-06.md))

- File: `src/app/api/v1/clients/bulk/route.ts:95-103`
- Lookup by `primaryBerthMooring` falls back to `dossier.interests[0]?.interestId ?? ''`.
  Empty-string `interestId` reaches the delete and silently matches
  zero rows; the link is silently retained while the audit log claims
  it was removed.

**R2-H4. External EOI runs five operations outside a transaction**
([reliability H4](audit-reliability-2026-05-06.md))

- File: `src/lib/services/external-eoi.service.ts:67-155`
- Storage upload + 4 DB writes are independent. Mid-flight failure
  leaves orphan PDFs in S3/MinIO and partial DB state.

**R2-H5. Bulk wizard double-submit treats `ConflictError('already archived')` as a per-row error**
([reliability H5](audit-reliability-2026-05-06.md))

- File: `src/app/api/v1/clients/bulk/route.ts:68-120`
- No idempotency key on the bulk endpoint. A double-submit (network
  retry, double click) makes the second response look like all rows
  failed even though the first succeeded.

**R2-H6. Webhook replay button has no UI permission gate (403 toast spam)**
([permissions H1](audit-permissions-2026-05-06.md))

- File: `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
- Replay button renders for any user who can load the page. Server gates
  on `admin.manage_webhooks`. Non-admins see enabled buttons; clicking
  surfaces a generic 403 toast.

**R2-H7. Bulk Archive bulk action exposed to roles without `clients.delete`**
([permissions H2](audit-permissions-2026-05-06.md))

- File: `src/components/clients/client-list.tsx:182-190`
- `sales_agent` and `viewer` see the Archive bulk action; clicking
  surfaces a 403 from preflight. Mirror the `canHardDelete` pattern:
  `const canBulkArchive = can('clients', 'delete');`

**R2-H8. Bulk add_tag / remove_tag exposed to viewer**
([permissions H3](audit-permissions-2026-05-06.md))

- File: `src/components/clients/client-list.tsx:165-181`
- Same pattern as R2-H7 — no UI gate; server gates on `clients.edit`.

**R2-H9. Bulk hard-delete silently skips rows that vanish between preflight and execute**
([permissions H4](audit-permissions-2026-05-06.md))

- File: `src/lib/services/client-hard-delete.service.ts:377`
- `if (!c) continue;` swallows any client that was archived/restored/
  deleted by another operator between preflight and execute. Operator
  sees a `deletedCount` lower than requested and no signal which IDs
  were skipped.

**R2-H10. Frontend: `webhook-delivery-log` and `audit-log-list` swallow fetch errors silently**
([frontend H3, H4](audit-frontend-2026-05-06.md))

- Files: `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`,
  `src/components/admin/audit/audit-log-list.tsx:150-175`
- Both wrap fetches in `try/finally` with no `catch`. Failed loads show
  spinner forever or stale data; user has no signal that anything
  failed. Surface via `toast.error` + inline retry banner.

**R2-H11. Frontend: `audit-log-card` renders as `<a href="#">` — page-jumps on mobile tap**
([frontend H5](audit-frontend-2026-05-06.md))

- File: `src/components/admin/audit/audit-log-card.tsx:96`
- Card view rows on mobile insert `#` in URL on tap (back-button trap).
  Render as button or div, or link to a useful destination.

**R2-H12. Frontend: `smart-archive-dialog` doesn't invalidate the dossier or single-client query**
([frontend H6](audit-frontend-2026-05-06.md))

- File: `src/components/clients/smart-archive-dialog.tsx:197-212`
- Detail page header keeps showing client as un-archived after a
  successful archive until hard reload. Add
  `qc.invalidateQueries({queryKey: ['clients', clientId]})` and
  `qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})`.

**R2-H13. Frontend: bulk tag mutation uses `alert()` and lacks `onError`**
([frontend H2](audit-frontend-2026-05-06.md))

- File: `src/components/clients/client-list.tsx:88-106`
- Native `alert()` blocks the page on partial failure; pure network
  failure shows nothing. Replace with `toast.warning` / `toast.error`.

**R2-H14. Email-template subject overrides are no-ops for 6 of 8 templates**
([missing-features V1](audit-missing-features-2026-05-06.md))

- Files: `src/components/admin/email-templates-admin.tsx:24-72` (UI),
  `src/lib/services/portal-auth.service.ts:120,332` (only consumers)
- Admin sees an "Overridden" badge after saving a custom subject for
  CRM invite, inquiry confirmation, residential templates, etc. — but
  the senders ship the hardcoded subject regardless. Wire
  `loadSubjectOverride(portId, key)` into the 6 missing senders.

**R2-H15. Branding admin saves 5 settings that nothing reads**
([missing-features V2](audit-missing-features-2026-05-06.md))

- Files: `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx`,
  `src/lib/services/port-config.ts:240-272`
- Logo URL, app name, primary color, header HTML, footer HTML all
  dead-end. `getPortBrandingConfig` has zero callers. **Multi-tenant
  promise broken — every port's emails ship Port Nimara's branding.**

**R2-H16. Reminder admin saves digest defaults that no scheduler applies**
([missing-features V3](audit-missing-features-2026-05-06.md))

- Files: `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx`,
  `src/lib/services/port-config.ts:284-306`
- Sales reps think they configured a daily digest at 09:00 in their
  TZ; they get fire-as-they-hit notifications instead. The digest
  scheduler doesn't exist.

### Round 2 — MEDIUM (selected highlights)

**R2-M1. Portal "My Memberships" tile is a dead-end** ([missing-features V4](audit-missing-features-2026-05-06.md))

- Tile on `/portal/dashboard` has no `href`; route doesn't exist. Either
  ship `/portal/memberships` or remove the tile.

**R2-M2. Company detail Documents tab is a "Coming soon" stub** ([missing-features V5](audit-missing-features-2026-05-06.md))

- `src/components/companies/company-tabs.tsx:230-234`. Same problem
  as the three already-noted "coming soon" stubs but on a different
  entity.

**R2-M3. Onboarding page is a static checklist not the wizard it advertises** ([missing-features V6](audit-missing-features-2026-05-06.md))

- The page literally says "what this page will become". Either build
  the wizard or relabel the landing card.

**R2-M4. Backup admin page is a docs page despite landing copy promising "on-demand exports"** ([missing-features V7](audit-missing-features-2026-05-06.md))

- Once C1 (worker imports) is fixed, the existing `database-backup`
  job is reachable; small lift to wire a "Take backup now" button.

**R2-M5. Inquiry inbox has zero triage actions** ([missing-features V8](audit-missing-features-2026-05-06.md))

- No "Convert to client", no "Resolve", no "Assign". `website_submissions`
  table is permanent; sales has to copy-paste emails into client forms.

**R2-M6. external-eoi grants only `documents.upload_signed` but mutates interest state** ([permissions M1](audit-permissions-2026-05-06.md))

- A custom role with `documents.upload_signed:true` + `interests.edit:false`
  can flip an interest to "signed" via the external-EOI route.

**R2-M7. `InlineStagePicker` never sends `override:true` — `override_stage` permission unreachable from the most-used UI path** ([permissions M2](audit-permissions-2026-05-06.md))

- Users with the perm have to fall back to the modal `InterestStagePicker`
  to actually use it.

**R2-M8. `sales_agent` granted `interests.override_stage:true` — likely copy-paste from sales_manager** ([permissions M3](audit-permissions-2026-05-06.md))

- All other trust-elevated flags are stripped from sales_agent. Needs a
  product decision; either flip to false or document intent.

**R2-M9. `bulk-archive-preflight` leaks dossier-loader error text in `blockers`** ([permissions M4](audit-permissions-2026-05-06.md))

- An attacker enumerating UUIDs can distinguish "doesn't exist" vs
  "exists but you can't see it". Replace with generic "Could not load
  dossier".

**R2-M10. Documenso void worker has no max-retry alert hook** ([reliability M2](audit-reliability-2026-05-06.md))

- A persistent 401/403 retries forever. On exhaustion, write back to
  `documents` (`cancellation_failed=true`) and notify admin.

**R2-M11. Mobile More-sheet missing residential, notifications, berth-reservations, website-analytics** ([missing-features V9](audit-missing-features-2026-05-06.md))

- Mobile users have zero path to entire feature domains. Add to
  `MORE_ITEMS`.

**R2-M12. Portal has no profile / change-password surface** ([missing-features V10](audit-missing-features-2026-05-06.md))

- Forces every portal user to use the forgot-password flow even when
  they remember their old password. Ship `/portal/profile`.

**R2-M13. Portal invoices show amounts but no PDF download** ([missing-features V11](audit-missing-features-2026-05-06.md))

- Documents page does have downloads; mirror the pattern.

(Plus several more medium/low items in the dedicated docs; see those
for the full set.)

---

## TRIAGE LIST (combined Round 1 + Round 2)

### Ship now — CRITICAL

1. **C1** — wire the 5 missing BullMQ workers (`worker.ts`, `server.ts`)
   — 5-line fix; every webhook + cron flow is currently dead.
2. **R2-C1** — make bulk archive enqueue Documenso voids + next-in-line
   notifications (return value plumbing in `bulk/route.ts`).
3. **R2-C2** — fix the destructive-red hover on the Restore button
   (`client-detail-header.tsx`). Trivial CSS fix.

### Ship this week — HIGH (security/UX with concrete user impact)

4. **H1** — rate-limit the hard-delete-request endpoints.
5. **H3** — collapse "no code" vs "wrong code" into one error message.
6. **H7** — three "coming soon" stubs in client/berth tabs.
7. **R2-H1** — fix smart-restore's silent `berth_released` no-op (or
   reclassify as `reversibleWithPrompt`).
8. **R2-H2** — add `for update` lock on the smart-archive berth status
   flip (TOCTOU race).
9. **R2-H3** — bulk-archive's wrong-interest fallback — empty-string
   interestId silently no-ops.
10. **R2-H6, R2-H7, R2-H8** — three permission UI-gate misses on
    bulk actions and the webhook-replay button. ~30 lines total.
11. **R2-H10, R2-H12, R2-H13** — frontend swallowed errors + missing
    invalidation + alert() instead of toast. Small fixes, immediate UX
    win.
12. **R2-H11** — `audit-log-card` `href="#"` mobile back-button trap.
13. **R2-H14** — wire 6 missing email-subject overrides through their
    senders.

### Next sprint — HIGH/MEDIUM (operational + multi-tenant correctness)

14. **R2-H4** — wrap external-EOI in a transaction.
15. **R2-H5** — bulk-archive idempotency key + treat already-archived as
    success in bulk.
16. **R2-H9** — bulk hard-delete should return `skipped: string[]`.
17. **R2-H15, R2-H16** — branding + reminder admin pages save settings
    nothing reads (silently broken multi-tenancy).
18. **H2** — audit-page-view de-dupe (don't spam on every filter change).
19. **H4** — synthetic seed needs documented dev-user setup or its own
    bootstrap script.
20. **H5** — Documenso bad-secret rate-limit per IP.
21. **R2-M1 through R2-M5** — portal memberships dead-end, company
    Documents stub, onboarding wizard, backup page, inquiry inbox triage.

### Backlog — MEDIUM/LOW + remaining items

22. The remaining MEDIUM/LOW from both rounds — see the dedicated docs.

---

## Headline numbers (combined)

- **3 CRITICAL** (worker imports, bulk-archive side-effects, restore-button hover)
- **22 HIGH** (security + UX with concrete impact)
- **~15 MEDIUM** (operational hygiene, multi-tenancy gaps, unfinished features)
- **~10 LOW** (cleanup, defensive)

Round 1 was a manual synthesis after agent-pool stalls; Round 2 was
four focused agents with hard finding caps that all completed inside
the watchdog window. Every finding is grounded in code references.
