# Prod-Readiness Audit — feat/documents-folders

**Date:** 2026-05-11
**Branch:** `feat/documents-folders` (67 commits ahead of `main`; 34 from this session's documents-hub-split work + 33 from Wave 11.B)
**Scope:** 17 parallel domain audits (data-structure & sales-process completeness appended at bottom)
**Test posture at audit time:** 1287/1287 unit + integration pass. TypeScript clean (4 pre-existing errors: 1 stale `.next/` build artifact, 3 in a Wave 11.B-era `InMemoryBackend` test stub).

## Headline

**~28 Critical, ~38 Important, ~36 Minor findings across 17 domains.** (Original 16-domain count was 23/32/30; Audit 17 added 5/6/6.)

A handful of the Criticals are real bugs in this session's work that need to be fixed on this branch before merging to `main`. A few are long-standing gaps that survived multiple iterations (storage migration script, `.env.example` URL) and should be fixed independently of this branch but before any prod cutover. Several are mobile/a11y issues that were never going to be caught without a running dev server, which the implementation pass didn't have.

**Recommendation:** fix the 23 Criticals before merging this branch. Triage Importants into "fix-before-prod" vs "follow-up-on-main". Minors → backlog.

Estimated effort to clear Criticals: 6-10 hours of focused work.

---

## Critical findings

Grouped by remediation domain. Each entry: brief rationale + file:line ref + fix sketch.

### A. Core feature regressions in this session's work

**A1. `handleDocumentCompleted` is not idempotent — Documenso retries duplicate `files` rows + orphan blobs**
`src/lib/services/documents.service.ts:1115`

`resolveWebhookDocument` returns the doc regardless of `status`. Two webhook deliveries (Documenso retries on 5xx) can both pass through and both insert `files` rows; the second `UPDATE documents SET signedFileId` clobbers the first and the first blob is permanently orphaned in storage with no DB row.

**Fix:** `if (doc.status === 'completed' && doc.signedFileId) return;` immediately after `resolveWebhookDocument`. Standard idempotency gate for this pattern.

**A2. Realtime hookup dropped by hub rebuild — multi-rep stale data**
`src/components/documents/hub-root-view.tsx`, `src/components/documents/entity-folder-view.tsx`

The pre-rebuild hub consumed `document:*` and `file:*` Socket.IO events via `useRealtimeInvalidation`. After the rebuild, both `HubRootView` and `EntityFolderView` have no realtime subscription at all. The remaining hook lives inside `FlatFolderListing`, which is torn down when navigating away. Result: rep A on `Clients/Smith/` will not see rep B's upload until manual refresh; webhook-completed signatures don't appear in the Signing-in-progress section.

**Fix:** lift `useRealtimeInvalidation` up to `DocumentsHub` with both `document:*` and `file:*` events targeting the prefix keys `['files']` and `['documents']`. TanStack Query prefix matching will invalidate the aggregated keys.

**A3. LEFT JOIN port_id in ON clause defeats `idx_docs_signed_file_id`**
`src/lib/services/files.ts:544`

```sql
LEFT JOIN documents d ON d.signed_file_id = f.id AND d.port_id = $portId
```

Planner picks `idx_docs_port` and applies `signed_file_id = f.id` as a residual filter. At scale this is 20 × N comparisons per page load instead of 20 point lookups. Same pattern in `documents.service.ts:1915` for the workflow projection.

**Fix:** drop `AND d.port_id = portId` from the ON clause and add `AND (d.port_id = portId OR d.id IS NULL)` to the outer WHERE. Or add a composite `(signed_file_id, port_id)` index. `files.port_id` is already scoped, so cross-port leak risk is zero.

**A4. Importer doesn't set `files.folder_id` — imported files invisible to folder queries**
`scripts/import-organized-documents.ts:196-208`

The `documents` row gets `folderId` correctly (line 216) but the companion `files` row does not. `files.folder_id` is a separate column. The backfill won't rescue these — it only acts on files with entity FKs set, and the importer sets none of those either.

**Fix:** copy `folderId` into the `files.values(...)` block alongside the document insert.

**A5. `chk_system_folder_shape` has NULL escape — corrupted system rows persist**
`src/lib/db/migrations/0051_documents_hub_split.sql:22-28`

`NOT system_managed OR entity_type = 'root' OR (...)` evaluates to `NULL` (not `false`) when `entity_type IS NULL` and `system_managed = true`. Postgres treats NULL as "not false" so the constraint passes. Confirmed by direct insert test.

**Fix:** add `entity_type IS NOT NULL` to the constraint, or restructure as `CHECK (NOT system_managed OR (entity_type IS NOT NULL AND (entity_type = 'root' OR (entity_type = ANY(...) AND entity_id IS NOT NULL))))`.

**A6. `document-folders.service.ts` has zero log lines — silent failures across the entire folder service**
`src/lib/services/document-folders.service.ts` (no `logger` import)

Orphan rows in `listTree` are silently dropped (line 83-84). The 50-attempt suffix-loop exhaustion throws `ConflictError` with no log. `ensureSystemRoots` "missing root after upsert" throws raw `Error`. At 3am you would have no diagnostic for folder-related failures.

**Fix:** `import { logger } from '@/lib/logger'`. Add `logger.warn` on orphan-detection, retry-exhaustion (both `ensureEntityFolder` and `syncEntityFolderName`), and the missing-root invariant in `ensureSystemRoots`.

**A7. `demoteSystemFolderOnEntityDelete` is not wired into `client-hard-delete.service.ts`**
`src/lib/services/document-folders.service.ts:650` (exported but zero callers)

`client-hard-delete.service.ts` exists. It clears entity FKs on `files` and `documents` inside its transaction but never demotes the system folder. After hard-delete: folder retains `system_managed=true` + the dead `entity_id`. The partial unique index `uniq_document_folders_entity` permanently blocks any future client folder that would get the same display name. Also a GDPR right-to-be-forgotten gap.

**Fix:** call `demoteSystemFolderOnEntityDelete(portId, 'client', clientId)` inside `hardDeleteClient`'s transaction (or as a post-commit hook with audit log). Confirm whether `companies`/`yachts` have analogous hard-delete services that also need wiring.

### B. Accessibility blockers (WCAG 2.1 AA failures)

**B1. Unlabeled search input**
`src/components/documents/documents-hub.tsx:265`

`<Input placeholder="Search by title..." />` — placeholder is not a label. Fails WCAG 1.3.1 / 4.1.2.
**Fix:** `aria-label="Search documents by title"`.

**B2. No `aria-pressed` on type-filter chips**
`src/components/documents/documents-hub.tsx:276-299`

Active state is purely visual. Screen readers can't tell which chip is selected. Fails WCAG 4.1.2.
**Fix:** `aria-pressed={typeFilter === t}` on each chip.

**B3. No `aria-expanded` on tree chevrons; folder-row labels lack context**
`src/components/documents/folder-tree-sidebar.tsx:125, 135-155`

The expand button has `aria-label="Collapse"` / `"Expand"` with no folder name, so SR users hear "Expand button, Expand button…" with no differentiation. And it lacks `aria-expanded` so the open/closed state is invisible.
**Fix:** `aria-expanded={open}`, `aria-label={\`${open ? 'Collapse' : 'Expand'} ${node.name}\`}`. Same pattern in `documents-hub.tsx:210-217` for the per-row signer expand.

**B4. `aria-label` on Lock SVG becomes part of button's accessible name**
`src/components/documents/folder-tree-sidebar.tsx:150-154`

`<Lock aria-label="System folder" />` inside the folder-select `<button>` produces accessible name "Smith System folder" rather than a separate badge announcement.
**Fix:** `aria-hidden="true"` on the SVG + `<span className="sr-only"> (system folder)</span>` after the folder name.

### C. Mobile blockers

**C1. FolderTreeSidebar stacks above main panel with no collapse toggle**
`src/components/documents/folder-tree-sidebar.tsx:32` — `w-full sm:w-60`

On mobile the entire folder tree renders above the document list. With any non-trivial tree, reps scroll past it to reach content. Every other secondary-nav page uses a Sheet or Collapsible.
**Fix:** wrap in a Sheet drawer (default closed on mobile) with a "Show folders" trigger button.

**C2. `border-r` on wrong axis at mobile breakpoint**
`src/components/documents/folder-tree-sidebar.tsx:32`

Right border draws on full-width-stacked element instead of bottom separator.
**Fix:** `border-b sm:border-r border-r-0`.

**C3-C7. 5 tap-target violations below WCAG 44×44px minimum**

- C3: chevron expand button (`folder-tree-sidebar.tsx:125`) — 20×20px
- C4: row expand chevron (`documents-hub.tsx:210-216`) — no sizing
- C5: "view signing details" (`entity-folder-view.tsx:82-89`) — ~20px tall
- C6: "Show all (N)" (`aggregated-section.tsx:101-108`) — ~18px tall
- C7: type-filter chips (`documents-hub.tsx:277-297`) — `py-0.5` gives ~24px

**Fix:** `min-h-[44px]` + `py-2` (or `py-1.5`) on each. Or wrap in `<Button size="sm">` where the visual change is acceptable.

### D. Long-standing infra gaps (independent of this branch, must fix before prod)

**D1. `migrate-storage.ts` migrates zero files — silent footgun**
`src/lib/storage/migrate.ts:40-43`

`TABLES_WITH_STORAGE_KEYS` is an empty array. The comment says "Phase 6a ships an empty list" — never followed up. Running `pnpm tsx scripts/migrate-storage.ts` flips the active backend but migrates nothing. Existing blobs in `files`, `berth_pdf_versions`, `brochure_versions`, `gdpr_exports`, `report_snapshots` become unreachable.

**Fix:** populate the table list with all five tables + their `storagePath`/`storageKey` columns. The `copyAndVerify` SHA-256 round-trip already works; it just needs entries to act on.

**D2. `.env.example` DOCUMENSO_API_URL has `/api/v1` baked in → double-path URLs**
`.env.example`

Current value: `DOCUMENSO_API_URL=https://documenso.example.com/api/v1`. The client appends `/api/v1/documents` etc., producing `https://documenso.example.com/api/v1/api/v1/documents`. Anyone copying the example file gets 404s from Documenso with no diagnostic. Applies to both v1 and v2 deployments.

**Fix:** change to `DOCUMENSO_API_URL=https://documenso.example.com` (bare host). Update the admin UI placeholder to match.

### E. Test theatre — assertions never run

**E1. Smoke spec `test.skip()` guards mask infrastructure failures**
`tests/e2e/smoke/04-documents-hub-aggregated.spec.ts:99-104`
`tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts:41, 129, 153, 165`

When the API setup step (client create, file upload, file list) returns non-2xx, the test calls `test.skip(true, ...)` and proceeds no further. Playwright reports skipped tests as passed — a green CI run hides whether the actual assertion would have succeeded.

**Fix:** convert skip-on-non-ok to `expect.fail()` so a 401 on setup becomes a real test failure. Skip should only fire when the precondition is genuinely "this scenario doesn't apply", not "the infrastructure broke".

### F. Webhook event coverage gap (with v1 + v2 support in scope)

**F1. `DOCUMENT_DECLINED` has no handler**
`src/app/api/webhooks/documenso/route.ts:146-214`

v2 distinguishes Decline (recipient refuses) from Reject (admin cancels). The switch handles `DOCUMENT_REJECTED` only. A v2-declined document leaves the CRM document in `sent` status indefinitely; the poller doesn't catch it either (only checks `COMPLETED` and `EXPIRED`).

**Fix:** add a `DOCUMENT_DECLINED` case to the switch. Behaviorally mirror `DOCUMENT_REJECTED` initially; product can refine if Decline vs Reject should differentiate downstream.

---

## Important findings (fix before prod, or as follow-up on `main`)

Listed by audit domain. Each has a file:line ref in its source audit; I'll quote the highlights here for triage.

### Security

- **`storagePath` + `storageBucket` exposed via aggregated files API** (`files.ts:533-534`) — internal storage paths reach authenticated rep clients via `GET /api/v1/files?entityType=X`. Auditors flagged this from both Security and Integration angles. Sanitize at service layer.
- **Missing `portId` on UPDATE in folder-move route** (`api/v1/documents/[id]/folder/route.ts:41-44`) — pre-flight read scopes by portId so no current exploit, but defense-in-depth gap that breaks if pre-flight is ever refactored.
- **Signer emails exposed to all `documents.view` holders** — confirm with product whether read-only roles should see signatory email addresses or get them redacted.

### Database / Migration

- **`uniq_document_folders_entity` doesn't cover `entity_type = NULL`** — rows with NULL entity_type but non-NULL entity_id can duplicate. Closes when CHECK constraint is tightened (A5 above).
- **Backfill transaction holds advisory lock across N `ensureEntityFolder` calls** — at 10k files the lock is held for minutes. Batch in chunks of 500.
- **`CREATE INDEX` without `CONCURRENTLY`** in migration 0051 — blocks writes briefly. Quantify: short-duration on small tables, moderate on prod-sized. Split for zero-downtime if needed.

### Concurrency / Error Paths

- **Storage blob orphaned on DB-insert failure** in `handleDocumentCompleted` — `storage.put` before `db.insert(files)`. No janitor. Long-standing tradeoff; document explicitly.
- **`ensureSystemRoots`/`ensureEntityFolder` outside backfill transaction** — folder rows persist if the wrapping tx rolls back. Idempotent so re-run heals.
- **`syncEntityFolderName` 50-attempt cap with concurrent renames to same target** — silent log + stale folder name. Accepted divergence.

### Performance

- **N+1 grows with linked entities** — leasing company with 50 yachts = 110 queries per page load. Worst case (5 companies + 100 yachts) = 216. Acceptable for now; future optimization: single CTE with grouping.
- **Count queries can collapse via window function** — `count(*) OVER ()` halves round-trip count at scale.
- **Missing composite indexes `(port_id, client_id)` / `(port_id, company_id)` / `(port_id, yacht_id)` on `files`** — same for `documents`. Add before prod backfill at scale.
- **`listDocuments` calls `listTree()` twice when `includeDescendants=true`** — pass already-fetched tree into `hydrateDocumentsWithDownloadUrl`.

### Data migration (importer)

- **System-root collision risk** — bucket folders named `Clients`/`Companies`/`Yachts` silently merge into auto-created system roots. Add a pre-flight check that warns when any top-level segment matches a system root name.

### Observability

- **Archive/restore hooks missing `portId` in log context** (`companies.service.ts:215`, `yachts.service.ts:193`) — clients has it; companies and yachts don't.
- **Backfill CLI has no row-count telemetry** — only "Backfill complete" on success. Want files-processed / folders-created / FKs-propagated counts.
- **No log on empty aggregated projection** — `assertEntityInPort` returning false produces a silent empty result. Log warn with `portId + entityType + entityId`.
- **`handleDocumentCompleted` outer catch loses `portId`** (line 1197).

### UI/UX

- **Em-dash in `SigningDetailsDialog` description** (line 62) — user-facing copy.
- **Em-dashes baked into aggregated group labels** (`FROM COMPANY — ACME CORP`) — rendered on every entity folder view. `files.ts:335`, `documents.service.ts:1877`. Replace with colon or slash.
- **Mixed `Loading...` (ASCII) and `Loading…` (Unicode ellipsis)** across components. Normalize.
- **Raw `partially_signed` status in `HubRootView`** — no StatusPill or underscore replacement. Apply `StatusPill` or at minimum `replace(/_/g, ' ')`.
- **"view signing details" button too subtle** — inline-text in a tight muted cluster, blends into the date. Consider `<Button variant="ghost" size="sm">`.

### Integration conformance (with v1 + v2 support)

- **Documenso poll worker double-fire of `handleDocumentCompleted`** writes a second blob + second `files` row and overwrites `signedFileId`. Confirmed by both concurrency and integration audits. Resolved by A1's idempotency gate.
- **Poll worker omits `portId`** when calling `handleRecipientSigned` / `handleDocumentCompleted` — multi-port correctness risk.
- **MinIO operations have no socket timeout** — TCP blackhole stalls workers indefinitely. `fetchWithTimeout` doesn't cover the minio client's `putObject`/`getObject`. Wrap with an external timeout (`AbortController` or `Promise.race`).
- **No 0-byte check on `downloadSignedPdf` result** — a 0-byte response from Documenso writes a permanent corrupt `signedFileId` with no recovery path.
- **`DOCUMENSO_API_VERSION` env defaults to `v1`** with no documentation in `.env.example` that v2 is supported. A v2-pointed deployment that misses the env var fires v1 code paths against a v2 instance.
- **`DOCUMENT_DECLINED` event handler** — already listed as Critical F1; mentioned again here because the integration audit captured it under v2-specific gaps.
- **`RECIPIENT_VIEWED` / `RECIPIENT_SIGNED`** v2 event aliases — currently silently dropped. Confirm whether v2 actually fires these or maps to `DOCUMENT_OPENED` / `DOCUMENT_SIGNED` like v1. If v2 fires them, add handlers.

### Realtime / Socket.IO

- **`useRealtimeInvalidation` is inside `FlatFolderListing`, not `DocumentsHub`** — torn down when navigating away. Lifting to DocumentsHub closes this and unblocks A2 cleanly.
- **`['document-folders']` query key has no realtime invalidation path** — rep B renaming a folder takes up to 30s `staleTime` to surface for rep A. Add a folder-rename socket emit + invalidate.

### Audit log completeness

- **`createFolder` has no audit log** (line 102-136) — inconsistent with rename/move/delete which all audit.
- **`handleDocumentCompleted` file insert has no audit** (line 1163-1180) — signed PDFs created with no audit trail.
- **`syncEntityFolderName` ignores `_userId`** — folder renames driven by entity rename leave no audit trail.
- **Archive/restore suffix helpers no audit** — parent entity action audits, but folder mutation doesn't.

### Type-safety

- **`entityType as 'client'|'company'|'yacht'`** in `documents-hub.tsx:134` — no runtime guard. Fix with `ENTITY_TYPES.has()`.
- **`INFLIGHT_STATUSES as unknown as string[]`** — replace with `[...INFLIGHT_STATUSES]`.
- **Loose `files?/workflows?` union + unconstrained `T`** in `AggregatedSection` — refactor to discriminated union + `T extends { id: string }`.

### Test quality

- **`mapWorkflowStatus` `partially_signed` fix has no regression test**.
- **`applyEntityRestoredSuffix` "restore without prior archive" path not tested**.
- **`folderId="" → null` validator transform has zero test coverage**.
- **`syncEntityFolderName` collision beyond `(2)` untested** — if `isSiblingNameConflict` ever mis-classifies the error shape, retries never fire and the test wouldn't notice.

### Mobile

- **DocumentsHub sets no `useMobileChrome`/`setChrome` title** — falls back to URL-segment title-casing.
- **FolderActionsMenu trigger overrides to 28×28px** — should use default `size="icon"` (44×44).
- **SigningDetailsDialog signer email no `truncate`** — long emails overflow on narrow viewports.
- **Breadcrumb tap targets too small** (`folder-breadcrumb.tsx:41-60`) — no padding.

---

## Minor (backlog)

Approximately 30 minor findings across all domains. Highlights:

- **Em-dashes in `CLAUDE.md`** (29 in prose bullets, all in pre-existing content; no new em-dashes added in commit `ab79894`) — backlog cleanup pass.
- **`@radix-ui/react-icons` unused** — safe to remove from `package.json`.
- **`@hookform/resolvers`, `zod`, `tailwindcss`** all have major-version updates available — DO NOT upgrade pre-cutover (breaking changes).
- **Sonnet color contrast on `muted-foreground/70` opacity variant** (`aggregated-section.tsx:94`) — ~3.2:1 fails WCAG AA for normal text. Drop the `/70` tint.
- **`<header>` element inside `<div>` not under a sectioning element** (`aggregated-section.tsx:92`) — wrong landmark scope; use `<div>` or `<h6>`.
- **`h3` → `h5` jump in SigningDetailsDialog** (skipped heading level).
- **`renameFolder` `updatedAt` test uses 10ms `setTimeout`** — fragile but `toBeGreaterThan` is OK; can drop the sleep entirely.
- **`MINIO_AUTO_CREATE_BUCKET`** bypasses zod env schema; undocumented in `.env.example`.
- **`DOCUMENSO_TEMPLATE_ID_EOI` + recipient ID vars absent from `.env.example`** with Port-Nimara-specific hardcoded defaults.
- **`voidDocument` raw `FetchTimeoutError` propagation** — no `CodedError('DOCUMENSO_TIMEOUT')` wrap. Both call sites handle gracefully; cosmetic.

---

## Audit-by-audit completion log

| #   | Audit                                       | Status | Critical | Important | Minor |
| --- | ------------------------------------------- | ------ | -------- | --------- | ----- |
| 1   | Security & multi-tenant isolation           | ✓      | 0        | 3         | 0     |
| 2   | Database & migration safety                 | ✓      | 1        | 3         | 3     |
| 3   | Concurrency, idempotency, error paths       | ✓      | 1        | 3         | 3     |
| 4   | Performance & query plans                   | ✓      | 1        | 3         | 3     |
| 5   | Data migration from old system              | ✓      | 1        | 1         | 3     |
| 6   | Production observability                    | ✓      | 2        | 4         | 3     |
| 7   | UI/UX                                       | ✓      | 0        | 5         | 4     |
| 8   | Integration conformance (Context7)          | ✓      | 0        | 0         | 3     |
| 9   | Dependency audit                            | ✓      | 0        | 0         | ~10   |
| 10  | Accessibility (WCAG 2.1 AA)                 | ✓      | 4        | 5         | 4     |
| 11  | Test quality & coverage                     | ✓      | 2        | 6         | 3     |
| 12  | Realtime / Socket.IO                        | ✓      | 3        | 2         | 1     |
| 13  | Audit log completeness                      | ✓      | 0        | 4         | 4     |
| 14  | Type-safety                                 | ✓      | 0        | 3         | 3     |
| 15  | Mobile / responsive                         | ✓      | 6        | 5         | 3     |
| 16  | Integration holes (MinIO + Documenso)       | ✓      | 2        | 5         | 5     |
| 17  | Data structure & sales process completeness | ✓      | 5        | 6         | 6     |

---

## Suggested remediation order

**Pre-merge (block this branch):**

1. A1 (concurrency idempotency) — 1 line, 5 minutes.
2. A2 (realtime hookup) — ~30 min: lift one hook up two layers in component tree.
3. A4 (importer folder_id) — 1 line in scripts/import-organized-documents.ts.
4. A5 (CHECK NULL escape) — 1-line migration patch + re-apply.
5. A6 (folder service logger) — add `import { logger }` + 3 warn calls.
6. A7 (demote on hard-delete) — 1 line in client-hard-delete.service.ts.
7. B1-B4 (a11y) — ~30 min combined: aria attributes only.
8. C1-C7 (mobile) — ~1-2 hours: Sheet wrap + tap-target padding.
9. E1 (test theatre) — convert skips to fails.
10. F1 (DOCUMENT_DECLINED) — add case to switch.

**Pre-prod cutover (independent of branch):**

- A3 (LEFT JOIN port_id) — performance fix.
- D1 (storage migration table list) — populate TABLES_WITH_STORAGE_KEYS.
- D2 (.env.example URL) — strip `/api/v1`.
- All Important security findings.
- 0-byte signed PDF check.
- MinIO socket timeout wrapper.
- DOCUMENSO_API_VERSION documentation + v2 event audit.

**Post-prod (backlog on main):**

- Important UI/UX (em-dashes, loading state consistency, status pill on HubRootView).
- Important audit log completeness.
- Important type-safety tightening.
- All Minor.

---

## Notes on session vs. pre-existing findings

Several Criticals (D1 storage migration script, D2 .env.example, A3 LEFT JOIN port_id, parts of the audit-log gaps and observability gaps) are long-standing — they survived multiple iterations of the codebase, sometimes since Phase 6a. Fixing them on this branch is fine but they're not regressions introduced by this session.

The session's actual regressions are: A1 (idempotency), A2 (realtime), A5 (CHECK NULL), A6 (folder service has no logger), A7 (demote not wired), B1-B4 (a11y missed during the UI rebuild), C1-C7 (mobile never tested), E1 (test theatre).

The dependency, integration-conformance (Context7), and type-safety audits are clean of Critical findings — your dep posture is solid and the implementation follows published specs.

---

## Audit 17 — Data structure & sales process completeness

**5 Critical, 6 Important, 6 Minor.** This audit walked the entire entity graph and the sales-process pipeline end-to-end. Most findings are not regressions from this session — they are gaps in the sales-process plumbing that pre-date the documents-hub-split work but matter for prod cutover. C-1 and C-3 are session-introduced; C-2, C-4, C-5 are long-standing.

### Critical (data graph + sales pipeline)

**G-C1. `deleteFolderSoftRescue` re-parents documents but not files — split delete behavior**
`src/lib/services/document-folders.service.ts:268-282`

The soft-rescue transaction `UPDATE`s `documents.folderId = newParent`, then deletes the folder row. The schema cascade on `files.folderId` is `ON DELETE SET NULL` (not `SET DEFAULT newParent`) — so any files in the deleted folder land at **root**, while documents in the same folder correctly land at the deleted folder's **parent**. A folder containing both will scatter on delete.

Fix: inside the transaction, between the documents UPDATE and the folder DELETE:

```ts
await tx
  .update(files)
  .set({ folderId: newParent })
  .where(and(eq(files.folderId, folderId), eq(files.portId, portId)));
```

**G-C2. Client hard-delete blocked by `scratchpadNotes.linkedClientId` RESTRICT FK**
`src/lib/services/client-hard-delete.service.ts:190-218` + `src/lib/db/schema/system.ts:180`

`scratchpadNotes.linkedClientId references clients.id` with no `onDelete` → defaults to RESTRICT. The hard-delete service nullifies six nullable FKs (files, documents, formSubmissions, emailThreads, reminders, documentSends) but skips `scratchpadNotes`. Any rep who scratchpad-linked a note to a client → hard-delete throws an FK violation and aborts the transaction.

Fix: add to the nullification block:

```ts
await tx
  .update(scratchpadNotes)
  .set({ linkedClientId: null })
  .where(eq(scratchpadNotes.linkedClientId, args.clientId));
```

**G-C3. Client hard-delete leaves ghost system folder with stale `entityId`**
`src/lib/services/client-hard-delete.service.ts:214-218`

The unique index `uniq_document_folders_entity` on `(portId, entityType, entityId)` enforces a singleton system folder per entity. Hard-delete removes the client row but does not call `demoteSystemFolderOnEntityDelete`. The folder persists with `systemManaged=true, entityType='client', entityId=<deleted-id>` — invisible in the sidebar but holding the unique slot.

Fix: after the client delete, fire-and-forget the demote:

```ts
void demoteSystemFolderOnEntityDelete(args.portId, 'client', args.clientId).catch(logger.error);
```

(This is the same wire-up A7 in the main report flagged — confirmed missing on the hard-delete pathway specifically.)

**G-C4. Five of seven berth-rule triggers are defined but never called**
`src/lib/services/berth-rules-engine.ts:37-44` vs `src/lib/services/documents.service.ts:798,894,1234`

`DEFAULT_RULES` defines triggers for `eoi_sent`, `eoi_signed`, `deposit_received`, `contract_signed`, `interest_archived`, `interest_completed`, `berth_unlinked`. Only `eoi_sent` and `eoi_signed` are passed to `evaluateRule` anywhere in the codebase.

Concrete consequences:

- Deposit received (invoice paid) → no berth state change. Should auto-mark berth as Sold.
- Contract signed → no berth state change.
- Interest archived → no "berth available" suggestion fires.
- Interest marked Won/Lost → no rule trigger.
- Interest unlinked from berth → no rule trigger (off-by-default, but configurable and silently dead).

Fix sketches:

- `invoices.ts:741` (after `advanceStageIfBehind('deposit_10pct')`):
  ```ts
  const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
  void evaluateRule('deposit_received', updated.interestId, portId, meta);
  ```
- `interests.service.ts:archiveInterest` after `softDelete`: fetch primary berth via `getPrimaryBerth`, then `void evaluateRule('interest_archived', ...)`.
- `interests.service.ts:setInterestOutcome` after the outcome write: `void evaluateRule('interest_completed', ...)`.
- `interest-berths.service.ts:removeInterestBerth` after delete: `void evaluateRule('berth_unlinked', ...)`.

**G-C5. `contract_sent` and `contract_signed` pipeline stages have zero auto-advancement triggers**
`src/lib/services/documents.service.ts` (absent)

`STAGE_TRANSITIONS` defines `contract_sent` and `contract_signed` and they render in the Kanban/funnel UI, but no code path calls `advanceStageIfBehind(..., 'contract_sent')` or `advanceStageIfBehind(..., 'contract_signed')`. Sending a reservation agreement → no stage advance. Completing one (signed PDF arrives, `contractFileId` set in `handleDocumentCompleted` ~line 887) → no stage advance.

Effect: deals stall at whatever stage they hit when the reservation agreement was sent, until a rep manually drags them in the Kanban.

Fix: in `documents.service.ts`:

- `sendDocument` pathway (~line 798): if `doc.documentType === 'reservation_agreement'`, fire `advanceStageIfBehind(..., 'contract_sent', meta, 'Reservation agreement sent')`.
- `handleDocumentCompleted` (~line 887, where `contractFileId` is set): fire `advanceStageIfBehind(..., 'contract_signed', meta, 'Reservation agreement signed')` and `evaluateRule('contract_signed', ...)`.

### Important (cross-entity gaps)

**G-I1. Portal email uniqueness is global, not per-port**
`src/lib/db/schema/portal.ts:40` — `uniqueIndex('idx_portal_users_email_unique').on(table.email)`

A client who has dealt with two ports under this deployment can only ever have one portal account. The second `createPortalUser` will throw a unique-constraint violation. Make per-port (`.on(table.email, table.portId)`) if multi-port is a real deployment scenario, or document as single-port-only.

**G-I2. `archiveInterest` skips `interest_archived` rule and `notifyNextInLine`**
`src/lib/services/interests.service.ts:985-1014`

Archive does the audit log + socket emit but does not (a) trigger the berth-availability rule, (b) notify the waiting list for the primary berth. The waiting-list code is only fired when the **client** is archived, not the **interest**.

Fix after `softDelete`: fetch primary berth → `evaluateRule('interest_archived', ...)` + `notifyNextInLine(primaryBerth.berthId, portId, meta.userId)`.

**G-I3. Yacht/company `restore` paths missing `applyEntityRestoredSuffix`**
`src/lib/services/yachts.service.ts:178` + `src/lib/services/companies.service.ts:200`

Archive sides call `applyEntityArchivedSuffix`. Restore paths do not exist for yachts/companies at all today — but when they are added (or if the entity-restoration logic moves to the `clients/archive` parity routes), `applyEntityRestoredSuffix` must be wired. `clients.service.ts:596` already does this correctly.

**G-I4. `berthRecommendations.interestId` has no FK constraint**
`src/lib/db/schema/berths.ts:134` — column comment says "references interests.id" but `.references()` is omitted.

If an interest is hard-deleted (currently only possible via `db:studio` or future migrations), stale `berthRecommendations` rows persist and skew the recommender's tier aggregates. Add `.references(() => interests.id, { onDelete: 'cascade' })` and generate a migration.

**G-I5. Portal invoices invisible for company-billed deals**
`src/lib/services/portal.service.ts:232`

`getClientInvoices` matches on `billingEmail in client.emails`. Invoices with `billingEntityType='company'` (the most common B2B pattern: client is an individual buying through their company) are not surfaced even when the client is the company's director. Extend the query to OR-in invoices where `billingEntityType='company' AND company.directorClientId = portalUser.clientId`.

**G-I6. `hub-counts` API endpoint is orphaned**
`src/app/api/v1/documents/hub-counts/route.ts:5-10` + `getHubTabCounts` in `documents.service.ts:397`

The hub rebuild on this branch removed the component that called this endpoint. Service function + route are dead code. Either wire a KPI strip back into `HubRootView` (the spec does call for this) or delete the route + service function.

### Minor

- **G-M1.** Website inquiry → client conversion is fully manual; `prefill_*` query params are hints only. `inquiry-inbox.tsx:119`.
- **G-M2.** Polymorphic array columns (`photoFileIds`, `attachmentFileIds`) have no FK protection. Files deleted via any future hard-purge path silently orphan these arrays.
- **G-M3.** `berthReservations.interestId` RESTRICT default (notNull, no `onDelete`) — intent (preserve history vs oversight) undocumented.
- **G-M4.** `setInterestOutcome` to `won` does not fire berth-sold; downstream of G-C4.
- **G-M5.** `advanceStageIfBehind` silently no-ops when `yachtId` is null at `open` stage. Walk-in EOIs (vessel not yet identified) stall invisibly at `open`.
- **G-M6.** `removeInterestBerth` emits socket + webhook but skips `evaluateRule('berth_unlinked')`. Downstream of G-C4.

### Impact on cutover gate

- **G-C2** is the most pressing for cutover: it is a hard error on a foreseeable action (any rep deleting a client with a linked scratchpad note → 500). Fix before any team testing.
- **G-C4 + G-C5** mean the berth-map status and Kanban columns will drift visually for every deal that progresses past EOI. This is not data corruption, but it will erode rep trust quickly during initial team testing. Fix before cutover.
- **G-C1** is a UX correctness issue; will surprise reps but won't lose data. Same-branch fix.
- **G-C3** is data-integrity hygiene; no immediate user-visible effect but pollutes the unique-folder slot. Same-branch fix.

### Updated headline

With Audit 17 folded in, the corrected count is **~28 Critical, ~38 Important, ~36 Minor** across 17 domains. The new Criticals (G-C2, G-C4, G-C5) are long-standing pre-existing gaps in the sales pipeline — they don't block this branch's merge to `main`, but they block prod cutover. G-C1 and G-C3 are this-branch issues and should be folded into the same fix pass as A1-A7.

### Suggested remediation order — addendum

After the A/B/C/D/E/F block from the main report:

1. **G-C1** — files folder UPDATE in `deleteFolderSoftRescue` transaction (1-line addition).
2. **G-C2** — nullify `scratchpadNotes.linkedClientId` in `clientHardDelete` (1-line addition).
3. **G-C3** — call `demoteSystemFolderOnEntityDelete` after client hard-delete (1-line addition).
4. **G-C4 + G-C5** — wire 6 missing berth-rule + pipeline-advance triggers (~30 min total, spread across invoices.ts, interests.service.ts, interest-berths.service.ts, documents.service.ts).

Total addendum effort: ~1 hour for G-C1/G-C2/G-C3, ~30 min for G-C4/G-C5, plus 1 migration regen for I-4 if you choose to fix it now.
