# 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 `` — 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.