# Performance + Behavioral Audit (P-05/09/13/14, B-01-22) — agent #8

**Headline:** 1 critical (B-01 INNER JOIN drops hard-deleted berth links), 1 high (B-16 AppShell remount destroys form state), 1 medium (P-09a leading-wildcard ILIKE), 17 clean.

**Counts:** 1 critical · 1 high · 1 medium · 1 low · 17 passing

---

## 🔴 CRITICAL B-01: Hard-deleted berth causes silent data loss across interest surfaces

- **File:** `src/lib/services/interest-berths.service.ts:55` (`getPrimaryBerth`), `:87` (`getPrimaryBerthsForInterests`), `:140` (`listBerthsForInterest`)
- **What:** All three helpers use `INNER JOIN berths ON berths.id = interestBerths.berthId`. When a berth is hard-deleted, the INNER JOIN silently drops the link.
- **Why it matters:** Interest detail page shows `berthId: null`, `berthMooringNumber: null`. Kanban card shows no berth chip. EOI generation produces empty field. `archiveInterest` path that calls `getPrimaryBerth` before evaluating berth rule returns null and **skips the rule entirely**.
- **Suggested fix:** Change all three `INNER JOIN` to `LEFT JOIN berths`. Callers already handle `null` mooringNumber. Add service-layer guard preventing hard-delete of berths with `interest_berths` rows (require unlink or soft-archive first).

## 🟠 HIGH 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`.
- **Why it matters:** A user editing a client name on desktop who resizes past the mobile breakpoint loses unsaved draft text. Multi-step modal forms (reconcile wizard) open during resize get unmounted.
- **Suggested fix:** Wrap shared content with stable `key`, or use CSS-only responsive layout so the children subtree never remounts. Alternatively `key={isMobile ? 'mobile' : 'desktop'}` only on the shell wrappers with `children` stable via Portal.

## 🟡 MEDIUM P-09a: Leading-wildcard ILIKE in `buildListQuery` prevents index use

- **File:** `src/lib/db/query-builder.ts`
- **What:** List search uses `ILIKE '%term%'` with leading wildcard, defeating B-tree and trigram-prefix indexes.
- **Why it matters:** Sequential scan on high-cardinality text columns; degrades at scale.
- **Suggested fix:** Migrate to `pg_trgm` GIN indexes on the searched columns, or move to FTS via existing `search_text` GIN where one exists.

## 🟢 LOW P-14: List endpoint `limit` allows up to 1000 rows

- **File:** `src/lib/api/list-query.ts`
- **What:** Generic list cap = 1000. Audit log is bounded to 200 with cursor pagination (better pattern).
- **Why it matters:** A 1000-row response with relations can blow the 256 KB budget.
- **Suggested fix:** Lower default cap to ~100; require explicit cursor pagination beyond.

---

## ✅ Passing checks

- P-05 No N+1 — all secondary fetches batched via `inArray`
- P-13 Audit FTS uses `to_tsvector('simple')` + GIN index + `plainto_tsquery('simple')` consistently (`src/lib/services/audit-search.service.ts`, migration `0014_black_banshee.sql`)
- B-02 Sara Laurent contract-without-yachtId renders correctly (overview tab guards yacht section; stage-gate only fires on `changeInterestStage`)
- B-03 `activeInterestsWhere` (`src/lib/services/active-interest.ts`) used in listInterestsForBoard, getInterestStageCounts, listBerths reconcile, recommender CTE
- B-04 / B-05 `formatBerthRange` correct: single (`A1`), contiguous (`A1-A3`), non-contiguous (`A1, A3`), cross-pontoon (`A1-A2, B5-B7`), dedup, non-canonical pass-through
- B-07 Tier B fires only when `activeInterestCount===0 && lostCount>0`; `lost_count` aggregates `LIKE 'lost%' OR cancelled`; heat scoring gated by `tier === 'B'`; fall-through policy enforces cooldown/never_auto_recommend
- B-08 `withPermission` (`src/lib/api/helpers.ts:328-340`) writes `permission_denied` audit row before 403 (fire-and-forget `void`)
- B-09 Same-stage no-op `if (existing.pipelineStage === data.pipelineStage) return STAGE_NOOP;` early-returns before DB/audit/socket (`src/lib/services/interests.service.ts:847-849`)
- B-10 Documenso webhook handles empty body / malformed JSON via try/catch returning `{ ok: false }` 200 + warning log (`src/app/api/webhooks/documenso/route.ts:176-182, 202`)
- B-11 `status_override_mode` transitions (null/manual/automated) all have audit coverage; reconcile clears to null, rules engine writes 'automated', admin UI writes 'manual'
- B-13 Catch-up wizard `pipelineStage === 'contract'` sends `outcome: 'won'` (`src/components/berths/catch-up-wizard.tsx:120`); reconcile route validates `z.enum(['won']).optional()`
- B-17 Bulk-add berths wizard step state persists in `BulkAddBerthsWizard`'s `useState`; no remount between steps
- B-18 NotesList handles 6 entity types (clients/interests/yachts/companies/residential_clients/residential_interests); `companyNotes.updatedAt` substituted via `createdAt` per CLAUDE.md
- B-19 `InlineEditableField` present on client/yacht/company/interest/residential-client/residential-interest/berth tabs (11 files)
- B-22 `markExternallySigned` (`src/lib/services/external-signing.service.ts:68-72`) updates `{ docStatus: 'signed', updatedAt: now }`. Note: catalog said "documentId=null, signedAt=now" but interests table has no such columns — the service is correct relative to schema.
