# Comprehensive Audit Findings — 2026-05-15

Discovery pass across all 19 areas of `docs/AUDIT-CATALOG.md`. Code-side via 9 parallel sub-agents + browser sweep via Playwright MCP. Per-agent raw output cached under `docs/audit-findings-tmp/`.

## Scoreboard

| Severity    | Count  |
| ----------- | ------ |
| 🔴 CRITICAL | 3      |
| 🟠 HIGH     | 15     |
| 🟡 MEDIUM   | 48     |
| 🟢 LOW      | 8      |
| **Total**   | **74** |

The 3 critical and the most actionable HIGH issues should head the next fix wave.

---

## 🔴 CRITICAL

### C-01 (B-01) — INNER JOIN on hard-deleted berth silently drops interest→berth links

- **Files:** `src/lib/services/interest-berths.service.ts:55` (`getPrimaryBerth`), `:87` (`getPrimaryBerthsForInterests`), `:140` (`listBerthsForInterest`)
- **What:** Three helpers use `INNER JOIN berths ON berths.id = interestBerths.berthId`. Hard-deleting a berth makes the join silently drop the row.
- **Impact:** Interest detail shows `berthId: null` / `berthMooringNumber: null`. Kanban card shows no berth chip. EOI generation produces empty mooring field. `archiveInterest` calls `getPrimaryBerth` before evaluating the berth rule — null result causes the rule to be **skipped entirely**.
- **Fix:** Switch all three to `LEFT JOIN berths`. Callers already handle null. Add service-layer guard preventing hard-delete of berths with `interest_berths` rows (require unlink or soft-archive first).

### C-02 (R-021) — `/setup` missing from `PUBLIC_PATHS` — bootstrap unreachable on fresh DB

- **File:** `src/proxy.ts:51-73`
- **What:** `PUBLIC_PATHS` includes `/api/v1/bootstrap/` but NOT `/setup`. Unauthenticated user → `/setup` → middleware redirects to `/login?redirect=/setup`. Login useEffect fetches bootstrap status, calls `router.replace('/setup')` → middleware again → infinite redirect loop.
- **Impact:** Fresh deployment (no super admin) is functionally deadlocked. The first operator cannot reach setup without already having a session — impossible on a fresh DB.
- **Fix:** Add `'/setup'` to `PUBLIC_PATHS`. `POST /api/v1/bootstrap/super-admin` already self-protects with `hasAnySuperAdmin()`.
- **Browser-verified:** Navigating to `/setup` unauthenticated redirects to `/login` (no `?redirect=` even). The bootstrap-status check at `src/app/(auth)/login/page.tsx:41` confirms: `if (payload.data?.needsBootstrap) router.replace('/setup');` — feeds the loop on fresh DB.

### C-03 (NEW, browser-discovered) — Generic `PATCH /api/v1/interests/[id]` bypasses ALL stage-transition guards

- **Files:** `src/app/api/v1/interests/[id]/route.ts:20-32` (calls `updateInterest`); `src/lib/services/interests.service.ts:701` (`updateInterest`); `src/lib/validators/interests.ts:68,90` (`pipelineStage` flows through `updateInterestSchema` to the service)
- **What:** The `/stage` endpoint (`src/app/api/v1/interests/[id]/stage/route.ts`) calls `changeInterestStage` which enforces `STAGE_NOOP` early-return, `canTransitionStage()` table guard, override-requires-permission, and override-requires-≥5-char-reason. The generic PATCH endpoint calls `updateInterest` which writes the full payload (incl. `pipelineStage`) directly to the DB with **none** of those guards.
- **Browser proof:**
  - PATCH `/api/v1/interests/<deposit-paid-id>` with `{ pipelineStage: 'enquiry' }` → **200 OK**, interest demoted to enquiry. (Same call via `/stage` correctly returned 400 with "Cannot move from Deposit Paid directly to New Enquiry. Use the override option ...".)
  - PATCH `/api/v1/interests/<eoi-id>` with `{ pipelineStage: 'eoi' }` (same-stage) → **200 with full 1249-byte body** instead of 204. F27 fix only works through `/stage`.
  - Backwards write via generic PATCH leaves `eoiDocStatus: 'sent'` while `pipelineStage = 'enquiry'` — corrupted state.
  - Audit row written as generic `action: 'update'` with diff, not `action: 'stage_change'` with proper metadata. Webhook event `interest:updated` not `interest:stageChanged`.
- **Impact:** Any caller (rep tool, integration, mistake in frontend) hitting the generic PATCH can drive an interest to any stage with no override permission, no reason, no audit-as-stage-change. Same-stage spam fires no-op writes that bump `updated_at` and emit redundant socket+webhook events. The corrupted-state surface (stage rolled back but doc-status still says signed) breaks downstream rules-engine evaluations that branch on stage.
- **Fix:** In `updateInterestSchema`, omit `pipelineStage` (force callers to use `/stage`); OR in `updateInterest`, when `pipelineStage` is in the payload, delegate to `changeInterestStage` with the full guard chain. Either prevents the bypass surface from existing.

---

## 🟠 HIGH

### H-01 (SC-02) — Multiple FKs `ON DELETE NO ACTION` while Drizzle declares them nullable

- **Files:** `src/lib/db/schema/interests.ts:29,32` (portId/clientId); `src/lib/db/schema/documents.ts:72,85,86,176` (clientId/fileId/signedFileId/signerId); `src/lib/db/schema/reservations.ts:18,24,25,27,28,33` (all 6 berthReservations FKs); `src/lib/db/schema/operations.ts:25` (reminders.clientId); `src/lib/db/schema/financial.ts:120` (invoices.pdfFileId)
- **What:** `.references(...)` without `{ onDelete }` emits `ON DELETE NO ACTION`. Hard-deleting a parent (client, berth, yacht, file) blocks at FK level.
- **Fix:** Add `{ onDelete: 'set null' }` for nullable FKs that should tolerate parent deletion; explicit `{ onDelete: 'restrict' }` for those that intentionally block (`interests.clientId` design intent is archive-first).

### H-02 (R-017/018) — CRM post-login redirect ignores `?redirect=` param

- **File:** `src/app/(auth)/login/page.tsx:79`
- **What:** Middleware redirects unauthenticated → `/login?redirect=<path>`. Login page never reads `useSearchParams()`; always `router.push('/dashboard')`.
- **Impact:** Email/bookmark/shared deep links into specific clients/interests silently dump to dashboard.
- **Fix:** Read `searchParams.get('redirect')`, validate same-origin (`startsWith('/')`, not `'//'`), use as push target.

### H-03 (R-023) — CRM invite token in query string leaks to access logs

- **File:** `src/lib/services/crm-invite.service.ts:71,233`
- **What:** `${env.APP_URL}/set-password?token=${raw}` — raw 32-byte token in query param. Portal flow was migrated to `#token=` fragment in 2026-05-14 specifically to keep tokens out of logs/Referer; CRM invite path missed the migration.
- **Impact:** Every nginx/Caddy access log line for `GET /set-password?token=<raw>` persists token to disk. Forwarded to SIEM/S3/monitoring → token visible to anyone with log access. Token grants account creation.
- **Fix:** Change `createCrmInvite` + `resendCrmInvite` to emit `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`. Update `set-password/page.tsx` to use the fragment-reading pattern from `PasswordSetForm` (`readTokenFromUrl()`) with `?token=` back-compat for outstanding tokens.

### H-04 (R-029) — `sign-in-by-identifier` 429 missing `Retry-After`

- **File:** `src/app/api/auth/sign-in-by-identifier/route.ts:47-51`
- **What:** Builds 429 response with `headers: rateLimitHeaders(rl)` which only emits `X-RateLimit-Limit/Remaining/Reset`. `enforcePublicRateLimit` adds `Retry-After`; this route uses `checkRateLimit` directly and skips it.
- **Impact:** RFC 6585 §4 violation. Automated clients can't back off correctly.
- **Fix:** Add `'Retry-After': Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)).toString()`.

### H-05 (AU-01a) — `toggleAccount` writes no audit row

- **File:** `src/lib/services/email-accounts.service.ts:86-116`
- **What:** Sets `isActive` on email account with no `createAuditLog` call. `connectAccount` (line 70) and `disconnectAccount` (line 139) do, but enable/disable in between is silent.
- **Impact:** Silently disabling an email account suppresses bounce-detection or reroutes replies — compliance gap on a security-relevant config change.
- **Fix:** Add `void createAuditLog({ action: 'update', entityType: 'email_account', entityId: accountId, newValue: { isActive: data.isActive }, ... })` inside `toggleAccount`.

### H-06 (AU-02) — Encrypted credential ciphertext stored in audit log without masking

- **Files:** `src/lib/services/settings.service.ts:66-76` + `src/lib/services/sales-email-config.service.ts:281-299`
- **What:** `updateSalesEmailConfig` calls `upsertSetting('sales_smtp_pass_encrypted', <ciphertext>, portId, meta)`. `upsertSetting` records `newValue: { value: '<ciphertext>' }`. `maskSensitiveFields` checks JSON keys against `SENSITIVE_KEY_FRAGMENTS`; the wrapping key `"value"` isn't in the list. Ciphertext lands verbatim in `audit_logs.new_value`.
- **Impact:** Audit log readable by all admins with `admin.view_audit_log`. DB read access exfils ciphertext; if `EMAIL_CREDENTIAL_KEY` is ever compromised, the historical audit log becomes a credential store.
- **Fix:** In `upsertSetting`, detect when key ends with `_encrypted` (or accept `redactValue?: boolean`) and record `newValue: { value: '[redacted]' }`.

### H-07 (AU-10) — Cascade-archived interests produce no individual audit rows

- **File:** `src/lib/services/clients.service.ts:578-618`
- **What:** `archiveClient` batch-archives open interests, writes ONE `entityType: 'client'` row with `newValue: { cascadedInterestIds: [...] }`. No per-interest rows. `search_text` doesn't include `new_value`, so searching for an interest ID returns nothing.
- **Impact:** Auditor querying for a specific archived interest sees no archive event; must know to look at parent client row.
- **Fix:** Loop over `archivedInterestIds` and emit per-interest `createAuditLog({ action: 'archive', entityType: 'interest', entityId, metadata: { cascadeSource: 'client_archive', clientId } })` (fire-and-forget).

### H-08 (EM-XX) — Sales transporter missing SMTP timeouts

- **File:** `src/lib/services/sales-email-config.service.ts:331-337`
- **What:** `createSalesTransporter` builds nodemailer transport with no timeout options. Compare `createTransporter` in `src/lib/email/index.ts:26-37` which uses `SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000 }`.
- **Impact:** Hung SMTP relay can stall send-out indefinitely. Email queue concurrency=5, maxAttempts=5. One stuck TCP connection → 2-min default × 5 retries = 10min/job × 5 slots = whole pool blocked for 10min by a single flaky send.
- **Fix:** Apply `SMTP_TIMEOUTS` constant to `nodemailer.createTransport` in `createSalesTransporter`.

### H-09 (B-16) — AppShell remounts children on breakpoint crossing, destroying form state

- **File:** `src/components/layout/app-shell.tsx:58-70`
- **What:** When `isMobile` flips on resize, the shell switches between `<MobileLayout>{children}</MobileLayout>` and the desktop `<div>...{children}...</div>`. React unmounts and remounts `children`, destroying any in-progress `useState` form drafts including `InlineEditableField`.
- **Impact:** User editing a client name on desktop who resizes past mobile breakpoint loses unsaved draft text. Multi-step modal forms (reconcile wizard) open during resize get unmounted.
- **Fix:** Wrap shared content with stable `key`, or use CSS-only responsive layout so children subtree never remounts. Alternatively `key={isMobile ? 'mobile' : 'desktop'}` only on shell wrappers with `children` stable via Portal.

### H-10 (U-059) — Unicode glyphs as status icons in portal documents page

- **File:** `src/app/(portal)/portal/documents/page.tsx:85-89`
- **What:** Signer status rendered as raw Unicode (`'✓'` signed, `'✗'` declined, `'○'` pending) inside colour-coded `<span>` with no `aria-label`.
- **Impact:** Screen readers read literal Unicode names. Project memory: decorative unicode glyphs explicitly flagged. `inline-stage-picker.tsx:443` comment confirms the pattern ("was ⚑ unicode glyph — replaced with a Lucide").
- **Fix:** Replace with `<CheckCircle2>` / `<XCircle>` / `<Circle>` Lucide icons + `aria-label`.

### H-11 (U-066) — Vaul Drawer used for mobile search overlay (violates Sheet doctrine)

- **File:** `src/components/search/mobile-search-overlay.tsx:6`
- **What:** `import { Drawer as VaulDrawer } from 'vaul'`. Search overlay is full-screen, not a bottom sheet. CLAUDE.md: Vaul reserved for mobile-bottom-sheet only (currently `MoreSheet` only).
- **Fix:** Convert to `<Sheet side="bottom">` or `<Dialog>` fullscreen. Custom visualViewport handling (lines 50-89) becomes redundant with Radix dialog backing.

### H-12 (U-076) — Native `alert()` for bulk-action failure feedback in 3 lists

- **Files:** `src/components/interests/interest-list.tsx:146`, `src/components/companies/company-list.tsx:73`, `src/components/yachts/yacht-list.tsx:66`
- **What:** Partial-failure feedback via `alert(...)`. `client-list.tsx:145` uses `toast.warning(...)` correctly.
- **Impact:** Native alert blocks main thread, can't be styled, fires in tests without suppression.
- **Fix:** Replace with `toast.warning(...)` matching `client-list.tsx`.

### H-13 (U-079) — Icon-only buttons missing `aria-label` (5 sites)

- **Files:** `src/components/notifications/notification-bell.tsx:65`, `src/components/files/file-grid.tsx:121`, `src/components/admin/forms/form-template-list.tsx:102`, `src/components/email/email-accounts-list.tsx:159`, `src/components/companies/company-members-tab.tsx:228`
- **Pattern reference:** `src/components/shared/folder-actions-menu.tsx:96` correctly uses `<span className="sr-only">More folder actions</span>`.
- **Fix:** Add `aria-label` to each, following the folder-actions-menu sr-only pattern.

### H-14 (NEW, browser-discovered) — `DELETE /api/v1/interests/[id]/outcome` with empty body crashes 500

- **File:** `src/app/api/v1/interests/[id]/outcome/route.ts:27-30`; `src/lib/api/route-helpers.ts` (parseBody)
- **What:** The DELETE handler calls `parseBody(req, clearOutcomeSchema)`. `clearOutcomeSchema` says `reopenStage` is optional. But DELETE with no body causes parseBody to throw an unhandled error → 500 internal-server-error JSON. Sending `{ reopenStage: 'qualified' }` returns 200.
- **Browser proof:** Two consecutive `DELETE /api/v1/interests/<wonId>/outcome` calls (no body) returned 500 with `requestId: bc807db5-...` / `d21b5b3e-...`. Same call with body `{}` would presumably also work (not tested) — the issue is empty-vs-omitted body.
- **Impact:** F26 reopen flow — when the user clicks "Reopen" without overriding the auto-detected previous stage, the request crashes. Frontend may always send a body, but the API contract claims optional and the wire-level test fails.
- **Fix:** In `parseBody`, treat empty request body as `{}` for DELETE/POST routes whose schemas have all-optional fields; OR in the route handler, parse the body conditionally on `req.headers.get('content-length') !== '0'`.

### H-15 (NEW, browser-discovered) — Sales-agent visiting an admin page silently bounces to dashboard (no 403 / feedback)

- **Files:** Middleware in `src/proxy.ts` and/or per-route admin layout
- **What:** Sales-agent navigating to `http://localhost:3000/port-amador/admin/audit` lands at `http://localhost:3000/port-amador/dashboard`. URL silently changes; no toast, no 403 page, no "Access denied" feedback. The API itself correctly returns 403 ("Insufficient permissions" or "No access to this port") — the UI just hides the failure.
- **Impact:** A rep clicking a deep link to an admin page (in an email, bookmark, or shared link) is silently redirected without explanation. They can't tell whether the link was wrong, whether their permission lapsed, or whether the page just doesn't exist. (The earlier A18 verification said "/admin/audit correctly 403s" at the API level, which is true — but the UI layer hides it.)
- **Fix:** Render a `/403` page or surface a toast on access denial in the admin route layout. Keep the URL on the failed route so users can verify what they tried to reach.

---

## 🟡 MEDIUM (45 findings — by area)

### Multi-tenancy (5)

| ID     | Title                                                   | File:line                                                        | Fix sketch                                                  |
| ------ | ------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- |
| M-MT01 | `updateDefinition` UPDATE missing portId in WHERE       | `src/lib/services/custom-fields.service.ts:136-145`              | Add `and(eq(...id), eq(...portId, portId))` to UPDATE WHERE |
| M-MT02 | Notes UPDATE/DELETE missing entityId scope              | `src/lib/services/notes.service.ts:846-850, 869-873, 897-901`    | Add `eq(...notes.<parent>Id, entityId)` to WHERE            |
| M-MT03 | Contact UPDATE/DELETE missing clientId scope            | `src/lib/services/clients.service.ts:737-741, 764`               | Add `eq(clientContacts.clientId, clientId)` to WHERE        |
| M-MT04 | `listForYachtAggregated` ownerClientId lookup no portId | `src/lib/services/notes.service.ts:276-283`                      | Add `eq(clients.portId, portId)`                            |
| M-MT05 | Webhook reads expose row before JS portId check         | `src/lib/services/webhooks.service.ts:103-108, 133-137, 170-174` | Move portId into `findFirst` WHERE                          |

### Schema (5)

| ID     | Title                                                                           | File:line                                                                                  | Fix sketch                                                                                            |
| ------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| M-SC01 | Migrations 0000-0036 not idempotent (no IF NOT EXISTS / DO blocks)              | `src/lib/db/migrations/0000_narrow_longshot.sql`, `0036_polymorphic_check_constraints.sql` | Standardize IF NOT EXISTS / DO block pattern for new migrations; document 0000-0036 not re-runnable   |
| M-SC02 | `companies` missing soft-delete partial index                                   | `src/lib/db/schema/companies.ts:39-45`                                                     | `CREATE INDEX IF NOT EXISTS idx_companies_archived ON companies (port_id) WHERE archived_at IS NULL;` |
| M-SC03 | FTS GIN index missing for `interests` and `berths`                              | `src/lib/db/migrations/0057_search_fts_indexes.sql`                                        | Add `CREATE INDEX CONCURRENTLY ... USING gin (...)` for both                                          |
| M-SC04 | `audit_logs.searchText` schema/DB mismatch (Drizzle plain, DB GENERATED ALWAYS) | `src/lib/db/schema/system.ts:53-54`                                                        | Annotate as non-updateable / generated marker                                                         |
| M-SC05 | `documents.clientId` Drizzle nullable but DB `ON DELETE NO ACTION`              | `src/lib/db/schema/documents.ts:72`, migration `0000_narrow_longshot.sql:814`              | Migration mirroring 0059's fix for `files.client_id`: drop + re-add with `ON DELETE SET NULL`         |

### Routes / Middleware (2)

| ID    | Title                                                                                    | File:line                           | Fix sketch                                                                                    |
| ----- | ---------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------- |
| M-R01 | `/portal/` blanket allowlist removes middleware backstop                                 | `src/proxy.ts:65`                   | Allowlist only unauthenticated portal routes individually; add middleware portal-cookie check |
| M-R02 | No explicit OPTIONS handlers, no CORS headers (defer until cross-origin consumer exists) | All `route.ts` under `src/app/api/` | Add explicit `Access-Control-Allow-Origin: <marketing-domain>` to public routes when needed   |

### Audit log (4)

| ID     | Title                                                             | File:line                                                           | Fix sketch                                                                                                              |
| ------ | ----------------------------------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| M-AU01 | FTS `search_text` covers only 4 fields; placeholder text misleads | migration `0014_black_banshee.sql:47-55` + `audit-log-list.tsx:360` | Change placeholder OR add `metadata` to GENERATED expression                                                            |
| M-AU02 | Admin audit log shows field names but no old→new diff             | `audit-log-list.tsx:290-305` + `audit-log-card.tsx:84-91`           | Add row-expand using `buildDiffLine` from activity-feed.tsx                                                             |
| M-AU03 | No audit log CSV export endpoint                                  | (absent)                                                            | `GET /api/v1/admin/audit/export/csv` reusing `searchAuditLogs`                                                          |
| M-AU04 | Outcome change uses `action: 'update'` not distinct verb          | `interests.service.ts:1047-1058`                                    | Add `'outcome_change'` to `AuditAction`; use in setInterestOutcome/clearInterestOutcome; add to dropdown + severity map |

### Documents/files (1)

| ID    | Title                                                                              | File:line                                        | Fix sketch                                                         |
| ----- | ---------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------ |
| M-D01 | Real-time invalidation event-name mismatch (`'file:created'` vs `'file:uploaded'`) | `src/components/documents/documents-hub.tsx:141` | Change to `'file:uploaded': [['files']]` matching other components |

### Security (1)

| ID    | Title                                                                              | File:line                                                                         | Fix sketch                                                                                 |
| ----- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| M-S01 | S3 access key ID stored plaintext in `system_settings` (secret encrypted, key not) | `src/lib/storage/index.ts:136`, `src/components/admin/storage-admin-panel.tsx:80` | Apply same `encrypt()` / `*IsSet` pattern as secret key; migration to re-key existing rows |

### Email + Integrations (8)

| ID     | Title                                                                               | File:line                                                                    | Fix sketch                                                                                      |
| ------ | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| M-EM01 | Portal activation/reset emails not threaded with portId — falls back to global SMTP | `src/lib/services/portal-auth.service.ts:163-164`                            | Pass `portId` as 6th arg to both `sendEmail` calls                                              |
| M-EM02 | No CC/BCC in main `sendEmail`                                                       | `src/lib/email/index.ts:54-68`                                               | Add optional `cc`/`bcc` to `SendEmailOptions`                                                   |
| M-EM03 | Bounce-to-interest linking not implemented                                          | `src/lib/services/sales-email-config.service.ts:13`                          | Wire BullMQ recurring job using imapflow to scan inbox for bounce NDRs (Phase 7 §14.9 deferred) |
| M-EM04 | Notification digest uses `'crm_invite' as any` for subject resolution               | `src/lib/services/notification-digest.service.ts:161-169`                    | Add `'notification_digest'` to `TEMPLATE_KEYS`; update digest service                           |
| M-IN01 | Presigned URL TTL fixed at 900s for portal downloads                                | `src/lib/storage/index.ts:240-254`; `src/lib/services/portal.service.ts:350` | Pass `expirySeconds: 4 * 3600` for portal links, or sign on-demand from API                     |
| M-IN02 | OpenAI receipt-scanner module-level instantiation, no credential health check       | `src/lib/services/receipt-scanner.ts:4`                                      | Guard `OPENAI_API_KEY` upfront; add health-check endpoint                                       |
| M-IN03 | Receipt OCR ignores per-port config; hardcoded `gpt-4o`                             | `src/lib/services/receipt-scanner.ts:19`                                     | Accept `portId`, call `getResolvedOcrConfig(portId)`, branch on provider                        |
| M-IN04 | Stale "pdfme" references in comments/seed                                           | `src/lib/db/seed-data.ts:807`, `src/lib/services/document-templates.ts:573`  | Update comments to reference pdf-lib AcroForm fill                                              |
| M-IN05 | Umami `testConnection` throws instead of typed `{ ok: false }`                      | `src/lib/services/umami.service.ts:80-101, 292`                              | Return `{ ok: false, error }` to match `checkDocumensoHealth`                                   |

### Performance + Behavioral (1)

| ID    | Title                                                                 | File:line                     | Fix sketch                                                                                          |
| ----- | --------------------------------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------- |
| M-P01 | Leading-wildcard `ILIKE '%term%'` in `buildListQuery` defeats indexes | `src/lib/db/query-builder.ts` | Migrate to `pg_trgm` GIN indexes on searched columns, or move to FTS via existing `search_text` GIN |

### Legacy enum drift (2)

| ID    | Title                                                                | File:line                                                                  | Fix sketch                                                               |
| ----- | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| M-L01 | Tenure type enum diverges between berths and reservations            | `src/lib/db/schema/berths.ts:65` vs `src/lib/db/schema/reservations.ts:32` | Pick canonical enum union; update both schemas + comments                |
| M-L02 | Reports stage rollup raw `pipelineStage` without `canonicalizeStage` | `src/lib/services/report-generators.ts:71-76, 88-106, 124-138, 176-192`    | Wrap row.stage with `canonicalizeStage()` before keying maps (defensive) |

### UX/forms (12)

| ID    | Title                                                                  | File:line                                                                      | Fix sketch                                                                                 |
| ----- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| M-U01 | Audit log uses inline div instead of `<EmptyState>`                    | `src/components/admin/audit/audit-log-list.tsx:524`                            | Replace with `<EmptyState title="..." />`                                                  |
| M-U02 | Two duplicate `EmptyState` components with incompatible APIs           | `src/components/ui/empty-state.tsx` vs `src/components/shared/empty-state.tsx` | Migrate 3 `ui/` callers to `shared/`, delete `ui/empty-state`                              |
| M-U03 | Required-field marker inconsistent                                     | `client-form.tsx:273`, `interest-form.tsx:281`                                 | Single pattern: `<Label>Field <span aria-hidden>*</span></Label>` + `aria-required="true"` |
| M-U04 | Help-text discoverability inconsistent                                 | `src/components/shared/filter-bar.tsx`, `client-form.tsx`                      | Document a rule (always-visible for constraints; tooltips only for icons)                  |
| M-U05 | Cancel/dismiss without unsaved-changes warning on ClientForm/YachtForm | `client-form.tsx`, `yacht-form.tsx`                                            | Add `isDirty` guard + discard AlertDialog matching InterestForm                            |
| M-U06 | FileUploadZone size limit not surfaced as client-side check            | `src/components/files/file-upload-zone.tsx:170`                                | Wire client-side size check before upload                                                  |
| M-U07 | No jump-to-page input in pagination                                    | `src/components/shared/data-table.tsx:420`                                     | Add small `<input type="number">` between Previous/Next                                    |
| M-U08 | No column resize/reorder on DataTable                                  | `src/components/shared/data-table.tsx`                                         | Opt-in `enableColumnResizing` per table via TanStack v8                                    |
| M-U09 | Invoice delete uses custom overlay, not AlertDialog                    | `src/app/(dashboard)/[portSlug]/invoices/page.tsx:167`                         | Replace with `<ConfirmationDialog>`                                                        |
| M-U10 | Success toast missing on ClientForm + InterestForm create/edit         | `client-form.tsx:215`, `interest-form.tsx:235`                                 | `toast.success(isEdit ? 'Client updated' : 'Client created')`                              |
| M-U11 | Logo preview `<img alt="">` should describe state                      | `src/components/admin/shared/settings-form-card.tsx:420`                       | `alt="Port logo preview"` or dynamic from field label                                      |
| M-U12 | Heading hierarchy inconsistent within tab components                   | `email-accounts-list.tsx:114`, `interest-contract-tab.tsx:130/251/291/364`     | Audit each tab; standardize h2/h3 nesting                                                  |
| M-U13 | DialogContent missing aria-describedby on minimal dialogs              | `compose-dialog.tsx:95` + ~40 others                                           | Add `<DialogDescription className="sr-only">` or `aria-describedby={undefined}`            |
| M-U14 | Mobile topbar title blank on list pages                                | `client-list.tsx`, `yacht-list.tsx`, `interest-list.tsx`, `berth-list.tsx`     | `useMobileChrome({ title, showBackButton: false })` per list                               |
| M-U15 | Invoices missing from mobile navigation                                | `src/components/layout/mobile/more-sheet.tsx:54`                               | Add `{ label: 'Invoices', icon: FileText, segment: 'invoices' }` to Operations group       |

---

## 🟢 LOW (8)

| ID     | Title                                                                                                  | File:line                                           |
| ------ | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------- |
| L-AU01 | Tier map sparse; new actions default to 'info' (`password_change`, `portal_activate`, `revoke_invite`) | `src/lib/audit.ts:220-222`                          |
| L-AU02 | Action filter dropdown missing 12 verbs                                                                | `audit-log-list.tsx:393-415`                        |
| L-AU03 | Entity-type filter dropdown missing 7 entries                                                          | `audit-log-list.tsx:88-102`                         |
| L-AU04 | Dead code — `listAuditLogs` (ILIKE)                                                                    | `src/lib/services/audit.service.ts`                 |
| L-D01  | `HubRootView` has 2 sections, not 3 (CLAUDE.md spec inaccuracy)                                        | `src/components/documents/hub-root-view.tsx:50-100` |
| L-D02  | `interest.yachtId` branch in chain doc spec is unreachable (interests.clientId NOT NULL)               | `src/lib/services/documents.service.ts:1225-1251`   |
| L-P01  | List endpoint `limit` cap = 1000 (audit log uses 200 + cursor as the better pattern)                   | `src/lib/api/list-query.ts`                         |
| L-L01  | Reports stage-revenue rollup raw `pipelineStage` (defensive concern, no active bug)                    | `src/lib/services/report-generators.ts:71-192`      |

---

## ✅ Areas verified clean

- Documents/files structurally solid across 22 checks (one event-name mismatch + 2 doc divergences only)
- Security XSS / SQLi / path traversal / SSRF / encryption-at-rest all clean (one S3 access key plaintext)
- Multi-tenancy entry-point port isolation correct everywhere; gaps are TOCTOU-style only
- Documenso v1+v2 routing complete and version-aware; magic-byte verification on both upload paths
- Public berths API + public health endpoint + cookie flags + CSP + CSRF all correctly configured
- Audit log core write path covers all sampled mutations; `maskSensitiveFields` covers expected PII fragments
- Better-auth session fixation, token expiry, audit-log tamper-resistance all clean
- Legacy 9-stage enum refactor — rank tables now include both legacy + modern keys (commit 9821106 closed the gap); all rendering surfaces route through `stageLabelFor` or `LEGACY_STAGE_REMAP`
- BullMQ retry/backoff configured; Redis noeviction enforced in compose; worker process bootstraps all 10 queues
- pdf-lib AcroForm fill, EOI merge tokens, `formatBerthRange` (single/contig/non-contig/cross-pontoon)
- Inline editing pattern present on all 6 detail page types; NotesList polymorphic across all 6 entity types

---

---

## Browser sweep findings (Playwright MCP) — 2026-05-15

Live exploratory testing of the dev instance (port-amador + port-nimara seeded) using Playwright MCP. All findings below were either (a) confirmation of static findings, or (b) new bugs only visible at runtime.

### New criticals + highs from browser sweep

- **🔴 C-03** — Generic `PATCH /api/v1/interests/[id]` bypasses ALL stage-transition guards (see C-03 above for full detail). The single most impactful new finding from the sweep.
- **🟠 H-14** — `DELETE /outcome` with empty body returns 500 (see H-14 above).
- **🟠 H-15** — Sales-agent → `/admin/*` silently bounces to `/dashboard`, no 403 page or toast (see H-15 above).

### New medium from browser sweep

- **M-NEW-1** — `/api/v1/me` and `/api/v1/me/ports` return 400 "Port context required" for non-super-admin callers without the `X-Port-Id` header. Super-admin works without the header. **Impact:** chicken-and-egg for the bootstrap flow that needs to know which ports a user has access to in order to choose one. Frontend likely passes the header from cookie state, but the contract is asymmetric per role. **Fix:** treat absent `X-Port-Id` on `/me/ports` as "list all ports the user has access to, regardless of context".
- **M-NEW-2** — Activity feed entity-type label rendered without separator: "Test Person 1interest", "Audit_loglist", "Settingrecom" — entity name + type concatenated. **File:** `src/components/dashboard/activity-feed.tsx` (the line that renders the entity label + type tag). **Fix:** add a separator (space, dot, or pipe) between name and type.

### Verifications confirmed clean in browser

| Check                              | Result                                                                                                                                                                 |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C-02 `/setup` deadlock             | ✅ confirmed: navigation redirects to `/login` (no `?redirect=` param even); `bootstrap/status` returns `needsBootstrap: false` on populated DB; loop fires when fresh |
| H-02 `?redirect=` ignored          | ✅ confirmed: signed in with `?redirect=%2Fport-amador%2Fclients%2Fsome-fake-id` → landed at `/port-amador/dashboard`                                                  |
| H-04 `Retry-After` missing         | ✅ confirmed: 429 fired on 2nd bad sign-in attempt, headers `x-ratelimit-limit/remaining/reset` present, NO `Retry-After`                                              |
| R-004 cross-port URL               | ✅ clean: `/port-amador/clients/<port-nimara-uuid>` shows friendly "Client not found... different port" page                                                           |
| MT-02 cross-port PATCH             | ✅ clean: `PATCH /api/v1/interests/<port-nimara-id>` with `X-Port-Id: port-amador` → 404 "We couldn't find that interest"                                              |
| Viewer permissions                 | ✅ clean: read 200, write same-port 403 "Insufficient permissions", write cross-port 403 "No access to this port"                                                      |
| F27 same-stage no-op               | ✅ clean via `/stage` endpoint (returns 204); ❌ broken via generic PATCH (200 + body) — see C-03                                                                      |
| Forbidden transition               | ✅ clean via `/stage` (400 with override-required-reason copy); ❌ bypassed via generic PATCH (see C-03)                                                               |
| Override no-reason                 | ✅ clean via `/stage` (400 "Override requires a reason (min 5 chars)")                                                                                                 |
| Override short-reason              | ✅ clean via `/stage` (same 400)                                                                                                                                       |
| AU-11 permission_denied filter     | ✅ activity feed shows no raw `permission_denied` rows                                                                                                                 |
| A2 legacy enum in feed             | ✅ no raw `deposit_10pct` / `eoi_sent` / `contract_signed` in activity feed text                                                                                       |
| R-008 mooring URL canonicalization | ✅ `A1`=200, `a1`=400, `A%201`=400, `A-1`=400                                                                                                                          |
| B-10 webhook empty/malformed body  | ✅ both return 200 `{ok:false}` (graceful)                                                                                                                             |
| Tag CRUD (AD-014)                  | ✅ 201 create + 204 delete                                                                                                                                             |
| Settings update (AD-008)           | ✅ 200 with persisted body                                                                                                                                             |
| Interest detail render             | ✅ EOI badge, milestone "EOI sent May 14, 2026", no raw legacy values, no errors                                                                                       |
| Interest reopen with reopenStage   | ✅ 200 ok                                                                                                                                                              |
| Public berths shape                | ✅ 117 berths, statuses split Sold=11 / Under Offer=49 / Available=57                                                                                                  |

### Out of scope for this sweep (not exercised)

- Live Documenso integration (requires real-API project — `pnpm exec playwright test --project=realapi`)
- IMAP bounce probe round-trip (requires SMTP+IMAP credentials)
- C-01 berth-INNER-JOIN bug — would require hard-deleting a berth in the live DB (destructive); static analysis already conclusive
- Browser-side cross-browser testing (BR-\* — Safari, Firefox, Edge)
- Drag-and-drop kanban interactions
- Visual regression baselines (`--project=visual` snapshots)
