# Active UAT — running findings

> **THIS IS THE CURRENTLY ACTIVE AUDIT DOC.** All new UAT findings land here regardless of which session captures them. Persists across sessions until the user explicitly says "wrap this round up and start a fresh one" — at which point archive this file with a date stamp (`YYYY-MM-DD-uat.md`) and start a new `active-uat.md`.
>
> Started 2026-05-26 after the drain commit `e9509dc` cleared the prior `alpha-uat-master.md` long tail. This file is the home for findings surfaced as the user walks through the running app. Append every item as a discrete entry — even premature / aspirational ones — so nothing gets dropped.
>
> **Methodology:** user drives the live CRM at `http://localhost:3000`, surfaces issues in chat (with screenshot + React-grab anchor when applicable). Each finding lands here in the matching bucket with file:line evidence and a status tag.
>
> **Status legend:**
>
> - `OPEN` — captured, not started
> - `IN PROGRESS` — currently being worked on this session
> - `SHIPPED in <hash>` — committed; commit message has detail
> - `QUEUED` — not for this session; deliberately deferred
> - `BLOCKED` — waiting on user input / external repo / clarification
>
> **Severity** (for bugs only): `critical | high | medium | low`.

> **Locked decisions — 2026-05-26 round.** User answered 11 blocking / clarifying questions. Inlined here for cross-finding reference; individual findings still carry their own context.
>
> - **Documenso comprehensive audit:** ship as 5 discrete sub-PRs — (1) persist `documensoId` immediately after create, (2) pre-flight validation, (3) state-machine refactor with `rollbackTo()` helper, (4) recipient ↔ Documenso identity reconciliation, (5) end-to-end test coverage + audit-log richness.
> - **Pre-flight validation for upload-for-signing:** hard-blocks Submit when any recipient has a missing email or any placed field's `recipientIndex` doesn't resolve. No override path.
> - **`/documents/new` wizard refactor:** (a) delete the upload branch, (b) drop the `inapp` template pathway, (c) per-port doc-type template defaults (`documenso_eoi_template_id` / `documenso_reservation_template_id` / `documenso_contract_template_id`) with admin-only override, (d) surface flow 3 (mark externally signed) from the dropdown menu, (e) drop `/documents/new` as a route — replace with `<GenerateDocumentDialog>` opened from the dropdown.
> - **Automate Signing button:** mid-flow enable picks up from next-in-order signer; completion broadcast goes to ALL recipients (signers + approvers + CCs); single combined mode (no partial-automate); manual override buttons stay visible with "Auto-firing soon" tooltip during automation.
> - **Webhook URL auto-PATCH on tunnel restart:** env-flag-gated via `DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1`. Prod can't be accidentally rotated by a stale dev script.
> - **Admin Webhook Health page:** explicit "Test now" button for ports with no webhooks received. No auto-fire on first page load.
> - **Per-port `documenso_signing_order` setting:** tri-state — SEQUENTIAL / PARALLEL / Use template default (null/empty state). Replaces the binary toggle.
> - **OverviewTab inheritance editing:** writes to the interest's `desired_*` column (override pattern). Save toast surfaces a follow-up "Update yacht record too?" CTA so the rep can promote the change up if the yacht itself is wrong.
> - **Public-map flag inheritance:** applies across every dialog with a map-flip affordance — EOI generate, External EOI upload, Reservation generate + upload, Contract generate + upload. Default: ON when ANY in-bundle berth has `is_specific_interest=true`, OFF otherwise.
> - **Cancel/Delete affordance audit:** sweep EVERY remove route (per-row EOI tab kebab, EoiCancelDialog, docs hub kebab, document detail Cancel + Delete, contract/reservation tab equivalents, NewDocumentMenu if any). Each one must run the same `cancelDocument`/`deleteDocument` service flow with permission check + Documenso void when `documensoId` set + status transition + onSuccess query invalidation + toast on error.
> - **Orphan-scan admin script:** deferred / out of scope. Dev DB nuke acceptable for UAT-session debris.

---

## Bucket 1 — Quick fixes (<15 min)

### Dialog primitive default too narrow → bump platform-wide

- **`SHIPPED locally (not yet committed)`** — _src/components/ui/dialog.tsx_ (DialogContent base default).
- **Fix applied:** default bumped from `sm:max-w-xl lg:max-w-3xl` to `sm:max-w-2xl lg:max-w-4xl`. Confirm dialogs override DOWN with `sm:max-w-md`; PDF preview / signing dialogs override UP with `lg:max-w-5xl` or `lg:max-w-[min(95vw,1400px)]`.
- **Symptom:** Dialog primitive's default is `sm:max-w-lg` (512px), which is far too narrow for most content (forms, file previews, signing details). Even the earlier per-dialog `lg:max-w-4xl` bump only fixed the dialogs I explicitly migrated; everything still using the default — including FilePreviewDialog (which overrides to `max-w-4xl` but PDFs are unreadable at that width) — stays cramped on desktop.
- **Fix:** bump the Dialog primitive base to `sm:max-w-2xl lg:max-w-4xl` so every Dialog gets a sane wide-screen default. Per-dialog overrides ride on top for cases that need wider (PDF preview) or narrower (confirm dialogs).

### FilePreviewDialog cramped for PDFs

- **`IN PROGRESS`** — _src/components/files/file-preview-dialog.tsx:109_.
- **Symptom:** opening a PDF lands in a `max-w-4xl` (896px) container on a 1920px+ desktop; PDF renders in a thin column with massive empty bands on both sides. Screenshot 2026-05-26.
- **Fix applied:** bumped DialogContent to `w-[min(95vw,1400px)] sm:max-w-none lg:max-w-none h-[85vh]` so PDFs get viewport-sized rendering capped at 1400px. Reference for "correct" width is the documents-tab preview which the user confirmed reads correctly.

### CreateDocumentWizard — doc-type labels lowercased

- **`SHIPPED locally (not yet committed)`** — _src/components/documents/create-document-wizard.tsx_ + _src/lib/constants.ts_.
- **Symptom:** doc-type dropdown renders `eoi`, `nda`, `reservation agreement`, `other` — lowercase, looks unfinished. Naive `.replace(/_/g, ' ')` doesn't capitalize.
- **Fix applied:** added `DOCUMENT_TYPE_LABELS` Record alongside the enum (`EOI`, `Contract`, `NDA`, `Reservation Agreement`, `Other`). Wizard reads from the map.

### CreateDocumentWizard — "Other" hint added

- **`SHIPPED locally (not yet committed)`** — _src/components/documents/create-document-wizard.tsx_.
- **Decision:** kept schema unchanged. Added an inline hint under the type selector when `other` is selected: "Use the Title below to describe the document — that's how it'll appear everywhere it's referenced."

### FlatFolderListing — needs padding above the list

- **`SHIPPED locally (not yet committed)`** — _src/components/documents/documents-hub.tsx_ FlatFolderListing.
- **Symptom:** the flat list sat flush against the subfolders UI above it — no vertical breathing room.
- **Fix applied:** wrapped FlatFolderListing's returned tree in `<div className="space-y-4">` so all three sub-sections (search/chip row, subfolders grid, documents list) get consistent vertical spacing.

### FlatFolderListing — root folder doesn't show uploaded files

- **`SHIPPED locally (not yet committed)`** — _src/components/documents/documents-hub.tsx_ FlatFolderListing + _src/lib/services/files.ts_ (listFiles) + _src/lib/validators/files.ts_ (already had folderId; service was ignoring it).
- **Root cause:** documents table (signature workflows) and files table (raw uploads) are separate; FlatFolderListing queried documents only.
- **Fix applied:** went with option B (parallel files query + client-side merge). `listFiles` now honours the `folderId` filter that was already accepted by the validator. FlatFolderListing runs a sibling `useQuery` against `/api/v1/files?folderId=X` and merges both sources into a unified `HubRow` list sorted by `createdAt desc`. New `renderFileRow` renders files with an "Uploaded file" type pill + "Stored" status pill, links the filename to the download URL. Existing FolderDropZone invalidation (`['files']` prefix) already covers the new query, so drag-drop AND New-document-menu uploads both refresh the list without a page reload.

### FlatFolderListing — chevron does nothing when no signers

- **`SHIPPED locally (not yet committed)`** — _src/components/documents/documents-hub.tsx:359+_.
- **React-grab anchor:** `<svg class="lucide lucide-chevron-right h-4 w-4" />` in FlatFolderListing.
- **Symptom:** every row renders a chevron button that's meant to expand signers detail. For docs with zero signers (manually uploaded, or signature docs that were cancelled/voided before recipients were added), clicking does nothing — the button toggles state but no signers panel exists to render.
- **Fix applied:** chevron button only renders when `totalSigners > 0`. Layout column kept (transparent placeholder span) so grid alignment doesn't jump.

### Interest drawer — inline client create

- **`SHIPPED locally (not yet committed)`** — _src/components/interests/interest-form.tsx_ + _src/components/clients/client-form.tsx_.
- **Symptom:** rep starts a new interest, realises the client isn't on file, has to close the drawer + navigate to Clients + create + come back. Yacht create was already inline ("Add new" button next to YachtPicker); client create wasn't.
- **Fix applied:** ClientForm gains an `onCreated(id)` callback; the create-branch mutation now returns `{ id }`. InterestForm renders an "Add new" Button next to the Client label (create-mode only — hidden on edit), opens the ClientForm Sheet, and auto-selects the newly-created client into the interest draft on success.

### InterestForm reset path dropped source='manual'

- **`SHIPPED locally (not yet committed)`** — _src/components/interests/interest-form.tsx_.
- **Symptom:** `defaultValues` set `source: 'manual'`, but the `!interest && open` reset path didn't include it. Reopening the drawer for a new interest landed on an unselected source dropdown.
- **Fix applied:** reset() block now includes `source: 'manual'` alongside the other create-mode defaults.

### UploadForSigningDialog — recipients show only one name, no email differentiator + role

- **`SHIPPED locally (not yet committed)`**
- **Files touched:** _src/components/documents/upload-for-signing-dialog.tsx_ (RECIPIENT_ROLE_META + RecipientRoleBadge helpers + placement-step sidebar render + FieldSidePanel dropdown).
- **React-grab anchor:** `<div class="space-y-1" />` in `FieldPlacementStep` in `DialogBody`.
- **Symptom:** placement-step's recipients sidebar (and the FieldSidePanel's "Assign this field to" dropdown) displayed only the recipient's NAME — no email, no role. UAT screenshot showed 4 recipients all literally named "matt 1, matt 2, matt 3, matt 4" with no way to distinguish them; reps editing real docs with duplicate names (e.g. multiple family members on a yacht purchase) hit the same problem. Worse: the failure of the "missing recipientId" error (separate finding below) is silently caused by which-email-maps-to-which-recipient confusion that the rep can't see.
- **Root cause:** the recipient rows in both surfaces were rendered as `r.name || r.email || #signingOrder` — falling back to email ONLY when name was blank. With non-blank names, email never showed. Role was tracked in state (`'SIGNER' | 'APPROVER' | 'CC'` on the Recipient interface) but never rendered.
- **Fix applied:**
  1. New `RECIPIENT_ROLE_META` constant maps each role to display label + tint (Signer blue, Approver amber, CC slate). New `RecipientRoleBadge` component renders the pill.
  2. Sidebar list rewritten as a two-line layout: line 1 is name + role badge, line 2 is the email (or "no email set" placeholder so the row doesn't shift). Email is also surfaced via `title` for hover-truncation tolerance.
  3. FieldSidePanel dropdown SelectItem rebuilt as a stacked layout — name + role badge on top, email muted below — so reps differentiating duplicate-named recipients can pick the right one without expanding the dropdown.
- **Alternatives considered + rejected:**
  - Showing only email and dropping name — rejected because the cleaner display people want is "Matthew Ciaccio · matt@gmail.com (Signer)", not pure email.
  - Color-coded chip strip instead of a dropdown — rejected for the same density reason captured in the prior "Assign this field to" finding.
- **Effort:** ~30 min (helpers + two render-site rewrites + tsc).
- **Cross-refs:** pairs with the "Assign this field to" label fix (just above). Both ship the same UAT round.
- **Acceptance criteria:** placement-step sidebar shows {color-dot, name, role badge, email} per recipient; FieldSidePanel dropdown options show {#order, name, role badge, email} per option; duplicate-named recipients are visually distinguishable by email.

### Documenso upload — silent partial-state when field placement fails

- **`SHIPPED locally (not yet committed) — comprehensive audit Phase 1 complete`**
- **Files touched (this fix):** _src/lib/services/custom-document-upload.service.ts_ (~line 400, placeFields try/catch). _src/components/documents/upload-for-signing-dialog.tsx_ (recipient UI sibling fix shipped separately).
- **Symptom:** rep uploads a PDF, places fields, hits Send. Error toast surfaces: `Documenso response missing recipientId for matt.ciaccio@gmail.com - cannot place fields`. Document appears in the CRM's signing UI AND in Documenso, recipients + roles are wired, but **all placed fields are missing**. The signing UI on the receiving end has no boxes to fill, which means a signer who receives the invite via email lands on a useless page.
- **Root cause:** in `placeFieldsFromUpload`, the placements were built via `fields.map(f => { if (!recipientId) throw ConflictError(...) ...})` BEFORE the surrounding try/catch. The synchronous throw from `map()` bubbled past the catch-and-rollback block that wraps `placeFields()`, so when the recipient lookup missed:
  1. Documenso envelope: already created + distributed (`sendDoc` succeeded earlier in the flow).
  2. Recipients: created with correct roles, signing URLs issued.
  3. Fields: never placed (the throw fired BEFORE the placeFields call).
  4. CRM document row: stuck in `'sent'` status because the rollback only fired inside the try/catch that the throw skipped over.
     Result: the partial state the user described.
- **Fix applied (this session):**
  1. The placements `map()` is now INSIDE the same try/catch that wraps `placeFields()`. Any throw — sync or async — triggers the rollback (Document row → cancelled, Documenso envelope → voided).
  2. Pre-throw `logger.error(...)` captures diagnostic state: the missed email, every email the Documenso response DID return. Future "why didn't this match" investigations have something to grep instead of guesswork.
  3. Comment block explaining the dedupe semantic (Documenso de-dupes by email at the envelope level, so duplicate emails across CRM recipient rows all map to the same Documenso recipientId — that's expected behaviour, not a bug).
- **Phase 1 audit shipped (5 sub-PRs delivered in this round):**
  1. **Persist `documensoId` immediately after `documensoCreate`** (P1.1). Was set only at the late success commit, leaving orphaned envelopes when any later step failed. Now the CRM row points at the envelope from the moment Documenso returns the id; rollback paths can find and void it. Catches future failures + future-proofs orphans.
  2. **Pre-flight validation hard-blocks Submit** (P1.2). UploadForSigningDialog computes a `submissionErrors` memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer also enforces the same checks (email regex + name presence) so direct API hits reject just as hard. No "I know there's a missing email" override.
  3. **State-machine refactor with `rollbackTo()` helper** (P1.5). Replaces three independent try/catches with one sequenced try around `create → send → place` and a single catch that calls `rollbackTo(reason)`. Tracks `state.step` + `state.documensoDocId` so future inserts (metadata writes between steps, etc.) inherit the rollback automatically. Idempotent — status flip is a no-op on a second call, voidDocument treats 404 as success.
  4. **Recipient ↔ Documenso identity reconciliation** (P1.6). After `documensoSend`, validates every distinct email we sent appears in `sentDoc.recipients`. If Documenso silently dropped one, a `ConflictError` fires before field placement so the rollback path triggers. Explicit error message names the missing email(s) for diagnosis.
  5. **End-to-end test coverage + per-failure audit-log entries** (P1.7). vitest suite extended with: blank email, whitespace-only email, malformed email, blank name, duplicate-emails-OK (Documenso dedupe semantic). `rollbackTo` writes a structured audit_log entry (`status=cancelled`, `failedStep`, `documensoEnvelopeId`, `errorClass`, `errorMessage`) so post-mortem investigation has structured data instead of pre-existing logger lines alone.
- **Still open (acknowledged but lower priority):**
  - **Idempotency on retry** — if the rep hits Send twice, do we double-create envelopes? Today the dialog disables the button while `sendMutation.isPending` so it's mitigated at the UI; service-layer guard via checking `documents.documensoId` before another `documensoCreate` would be belt-and-braces. Queued for follow-up.
- **Cross-refs:**
  - The `/documents/new` wizard refactor (Bucket 3 — wizard refactor finding) touches the same end-to-end flow — bundle the two so the same audit doesn't re-investigate the upload-for-signing service twice.
  - This is the SECOND time a multi-step Documenso flow has had a rollback gap — the first was the EOI auto-cancel/replace flow (fixed earlier in `65ff596`). Pattern: every multi-step orchestration that touches Documenso needs end-to-end rollback OR pre-flight validation. The audit doc's broader "activity feed comprehensive copy" finding mentioned a similar discipline gap; both should land before more multi-step features ship.
- **Open questions for the user:**
  1. **Are you okay with the comprehensive audit being one larger PR (~1-2 days focused), or should it ship as discrete sub-PRs (pre-flight + state-machine + tests)?** Trade-off: single PR is faster but harder to review; sub-PRs are reviewable but you'd see intermediate states.
  2. **Should the pre-flight validation block the dialog Submit button entirely, or surface an inline error and let the rep submit anyway (with "I know there's a missing email" override)?** Default proposal: hard block — Documenso's API can't recover from missing emails, so submitting anyway is guaranteed-to-fail.

### BerthRecommenderPanel — hide entirely when no desired dimensions set

- **`SHIPPED locally (not yet committed)`**
- **Files touched:** _src/components/interests/interest-tabs.tsx_ (~line 1467 Overview inline render + ~line 1577 dedicated tab entry + ~line 1521 hasDesiredDims gate variable + ~line 711 OverviewTab inner gate).
- **React-grab anchor:** `<div class="flex flex-col s..." />` in `Card` in `BerthRecommenderPanel`.
- **Symptom:** the recommender card rendered even when the rep hadn't entered any desired dimensions on the interest — surfacing only the "Set desired dimensions to see recommendations." guidance subtitle. User flagged that the card AND the dedicated "Berth Recommendations" tab should both be hidden in that state so reps aren't distracted by an empty placeholder.
- **Root cause:** previous design intentionally kept the panel always-mounted with inline guidance ("plan §5.3 — always-mounted card driven by the interest's desired dimensions"). User-experience preference now flips that to hide-entirely.
- **Fix applied:**
  1. Computed `hasDesiredDims = toNum(interest.desiredLengthFt) !== null` once near the top of the InterestTabs component, and once inside OverviewTab (because the Overview's inline render lives inside the child).
  2. Overview tab's BerthRecommenderPanel mount wrapped in `{hasDesiredDims ? <Panel /> : null}` — disappears entirely until length is captured.
  3. Dedicated "Berth Recommendations" tab object spread conditionally into the tabs array (`...(hasDesiredDims ? [tabObject] : [])`) so the tab strip's tab itself vanishes — not just the content. Rep doesn't get a dead-end tab.
- **Why gate on length only (not all three dimensions):** length is the primary ranking input in the recommender's SQL; width / draft fall back to length when missing. Requiring all three would hide the panel for partial-data interests where the recommender still has signal.
- **Alternatives considered + rejected:**
  - Show the panel but collapsed by default — rejected because reps still see the empty card; defeats the user's "hide entirely" ask.
  - Keep the dedicated tab but show the empty-state inside — rejected for the same reason; the user wants the tab gone too.
- **Effort:** ~15 min.
- **Cross-refs:** related to the Bucket 3 wizard refactor / OverviewTab inheritance finding — both touch what gets shown to a rep on the Overview tab as a function of what data is present.
- **Acceptance criteria:** an interest with `desiredLengthFt = NULL` shows no recommender card on Overview AND no "Berth Recommendations" tab in the strip. Setting desired length via the inline editor causes both to appear immediately (TanStack Query refetch).

### Per-berth public-map flag — should inherit on subsequent surfaces

- **`OPEN — needs user clarification on which surface specifically`**
- **React-grab anchor:** `<label class="flex items-cent..." />` in `DismissableLayer` in `FocusScope` in `Presence` (i.e., inside a Radix Dialog or Sheet).
- **User's message (verbatim):** "this should inherit from on the overview page if the berths on the interest record are marked as being changed/updated on the public map."
- **Best-guess interpretation:** the label-anchor lives inside a dialog (DismissableLayer / FocusScope wrap is Radix's modal portal). Most likely candidates given recent UAT focus:
  1. **EOI generate dialog** (`src/components/documents/eoi-generate-dialog.tsx`) — when the rep generates an EOI from the dialog, a checkbox controls whether the in-bundle berths' public-map status flips to "Under Offer." That checkbox should default to ON when any of the linked berths already have `is_specific_interest=true`, OR be defaulted based on those existing flags.
  2. **External EOI upload dialog** — same logic, parallel checkbox.
  3. **Reservation generate / external upload** — same pattern at a later stage.
  4. **Bulk berth-tagging surfaces** — less likely given the recent flow.
- **Root cause hypothesis:** these dialogs currently default their map-flip checkbox to a static value (probably `true`), without reading the existing per-row `is_specific_interest` flags on the interest's `interest_berths` rows. So a rep who explicitly turned the flag OFF on the linked-berths list (because they didn't want the map to flip yet) gets the dialog overriding their choice.
- **Fix proposal (when target surface is confirmed):**
  1. Query the interest's `interest_berths` rows when the dialog opens. Derive the default: if ANY in-bundle berth has `is_specific_interest=true`, default the dialog's checkbox to true. Otherwise default false.
  2. Better: surface a per-row indicator inside the dialog showing the current map flag state per berth, so the rep sees which berths will / won't flip and can override per-row.
  3. Wire submit to honour those per-row toggles instead of a single global checkbox.
- **Effort:** ~30 min for option 1 (single dialog), ~1.5h for option 2 (per-row UI) once the target dialog is identified.
- **Open questions for the user:**
  1. **Which dialog were you looking at when you flagged this?** Best to confirm before I touch any code — the label anchor doesn't uniquely identify it. Screenshot of the dialog would close the gap immediately.
  2. **Default semantic:** when ANY in-bundle berth has the flag on, should the dialog default the public-map flip to ON, or should it match the MAJORITY of berths' flags, or should it always be a deliberate per-dialog choice?

### Documenso upload — title transfer (verification + concern)

- **`VERIFIED WORKING (no fix needed); UX cue queued`**
- **Files inspected:** _src/lib/services/custom-document-upload.service.ts_ (line 388 `documensoCreate(title, ...)`).
- **User concern:** "not sure if the name I gave the document transferred through to the documenso document (not sure if i gave it a name or left it default)."
- **Verification:** the upload-for-signing service passes the `title` field through to `documensoCreate(title, pdfBase64, ...)` at line 388. Documenso's create call accepts the title verbatim. Same pattern in the EOI generate flow (template-based) — title is sent via the template-generate API.
- **Why the user couldn't tell:** the dialog's submission flow returns to the EOI tab + document list without surfacing the title that ended up on the Documenso side. If the rep left it default (no title input) the local CRM defaulted to something like "Dashboard report — 22_05_2026" (per screenshot evidence) — Documenso received exactly that string. Nothing was lost.
- **Queued UX fix (small):** after a successful send, show the title prominently in the success toast ("Sent for signing: 'Dashboard report — 22_05_2026' → Documenso") so the rep can immediately confirm what name landed on the receiving side. Bundle with the broader Documenso upload audit (above).

### Documenso upload + delete — orphaned envelopes when CRM document row has no documensoId

- **`OPEN (multiple linked bugs; root cause shared with the silent-partial-state finding above)`**
- **Files implicated:**
  - _src/lib/services/custom-document-upload.service.ts:498_ (`documensoId` is only written to the CRM row AFTER `placeFields` succeeds).
  - _src/lib/services/documents.service.ts:648_ (`deleteDocument` — best-effort void only runs `if (existing.documensoId)`; skips silently when null).
  - _src/lib/services/documents.service.ts:2220_ (`cancelDocument` — same gated void at line 2240).
  - _src/lib/services/documents.service.ts:192_ (`listDocuments` filters out `status='deleted'` by default).
  - _src/components/interests/interest-eoi-tab.tsx:121_ (EOI tab query).
- **Symptom chain (UAT 2026-05-26):**
  1. Rep uploads a custom doc via UploadForSigningDialog → field placement throws (the "missing recipientId" bug captured above). Before my session fix, the throw bypassed the rollback. So:
     - Documenso side: envelope created, recipients distributed, no fields placed.
     - CRM side: document row at `status='draft'`, `documensoId=NULL` (never written because line 498 is after the throw).
  2. Rep "removed the EOI" via the CRM UI — but the doc STILL displays as DRAFT in the EOI tab.
  3. Rep also confirms it wasn't deleted from Documenso side either.
- **Root cause (multi-part):**
  - **A. CRM lost the link to Documenso.** Because step 1 left `documensoId=NULL` on the CRM row, both `deleteDocument` and `cancelDocument` skip the Documenso void call (`if (existing.documensoId)` short-circuits). The CRM has no way to find the envelope to void. Documenso is now hosting an orphaned envelope.
  - **B. Whatever "remove" action the rep took didn't transition the status.** The screenshot shows the doc still as DRAFT after the rep's remove attempt. If `cancelDocument` had run, status would be `cancelled`. If `deleteDocument` had run, the row would be filtered out of the EOI tab list (line 195 excludes `status='deleted'`). So the rep's action either errored silently OR triggered a route we haven't identified.
  - **C. The earlier silent-partial-state bug is the seed.** Without my session fix to the rollback, every failure of `placeFields` left a phantom draft + orphaned envelope. Reproduced reliably until the rollback fires correctly.
- **Hypothesis ladder for the "remove" action that didn't take:**
  1. The rep clicked a cancel/delete affordance but the request 4xx'd (permission denied, validation error) and the toast was missed. The list query never re-ran because the mutation didn't onSuccess-invalidate.
  2. The rep deleted from Documenso UI directly (not the CRM), and confused that with a CRM-side remove. The CRM still has the row.
  3. There IS a CRM-side button that hit a route we missed — e.g. a soft-archive that doesn't change status.
- **Fix proposal (multi-layer):**
  1. **Persist `documensoId` IMMEDIATELY after `documensoCreate`, not at the end.** Move the `UPDATE documents SET documensoId=...` call to right after `documensoCreate` succeeds (line ~388). Subsequent failures will still rollback the status, but the CRM retains the Documenso reference so void calls work. Acceptable risk: the row briefly has a documensoId but status='draft'; the rollback path resolves it.
  2. **Audit every CRM-side "remove EOI / cancel doc / delete doc" affordance.** Each one should: (a) check the rep has permission, (b) call the right service (`cancelDocument` for active flows, `deleteDocument` for drafts), (c) onSuccess-invalidate the relevant queries, (d) surface toast on error not just silently swallow. List candidates: EoiCancelDialog (line 25 of interest-eoi-tab), the EOI tab's per-row kebab actions (currently in interest-eoi-tab.tsx near the doc list render), the docs hub kebab actions, the document detail page's Cancel/Delete buttons.
  3. **Surface "this row has no Documenso link" in the UI.** When a CRM doc has documensoId=NULL but status not in {draft (pre-send), deleted}, render a small warning chip ("Documenso link lost — cancel + recreate this doc") with a "Repair" CTA that voids the envelope IF the rep can supply a Documenso id, or marks the doc cancelled + lets them recreate.
  4. **Reconciliation cron / repair script.** Periodic (or admin-triggered) job that lists Documenso envelopes the CRM doesn't have a row for, surfaces them for review. Catches orphans across upgrades / past partial failures.
- **Effort:**
  - Fix #1 (persist documensoId early): ~20 min including a test that verifies the rollback still voids correctly.
  - Fix #2 (cancel/delete affordance audit): ~2h depending on how many call sites exist.
  - Fix #3 (UI orphan warning): ~1h.
  - Fix #4 (reconciliation script): ~2h.
- **Cross-refs:**
  - The earlier finding (above) — "Documenso upload — silent partial-state when field placement fails" — fixes the rollback path going forward. THIS finding addresses the orphans created BEFORE that fix landed + the cancel/delete affordances that miss the void path generally.
  - Pairs with the comprehensive Documenso upload audit (Bucket 3 — referenced above as `Documenso upload — silent partial-state ...`).
- **Open questions for the user:**
  1. Which "remove" action did you click — the per-row kebab in the EOI tab, the EoiCancelDialog, the docs hub kebab, or the document detail page Cancel/Delete button? Knowing which path you used narrows the diagnosis.
  2. Is the orphaned envelope in Documenso still there (you said you deleted from Documenso side too — did that succeed)? If yes, the orphan is gone and the CRM-side cleanup is the only remaining work. If no, we need the manual repair pattern in the meantime.
  3. Do you want a one-time admin script that scans for orphaned Documenso envelopes / dangling CRM rows now (to clean up everything created during this UAT session), or is that overkill and you'd rather just nuke the dev DB?

### Document signing flow — copy-link parity across surfaces

- **`SHIPPED locally (not yet committed)`**
- **Files touched:** _src/components/documents/signing-progress.tsx_ (the canonical shared component).
- **React-grab anchor:** `<div class="relative flex i..." />` in `SigningProgress` in `ActiveEoiCard` in `InterestEoiTab`.
- **Symptom:** rep wanted to copy a signer's signing link to send via WhatsApp / Slack / in person, but the per-signer row only showed "Send invitation" (or "Send reminder") — Copy link wasn't visible because it was rendered behind a conditional that hid the button entirely when `signingUrl` was falsy. So if Documenso hadn't issued the URL yet, or the field wasn't populated on the signer record, the rep couldn't copy at all and had no signal that copy was even an option.
- **Root cause:** the previous render at signing-progress.tsx:400 read `{signer.status === 'pending' && signer.signingUrl ? <CopyButton /> : null}` — both pending status AND a non-empty URL were required. Reps with a freshly-created envelope (URL not yet on the row) saw only the Send invitation button.
- **Fix applied:** changed the condition to render the Copy link button whenever `signer.status === 'pending'`, and disable the button (with a clarifying tooltip — "Signing URL is not available yet — Documenso issues it once the document has been sent.") when `signingUrl` is missing. Available tooltip: "Copy this signer's signing link to your clipboard so you can share it directly (Slack, WhatsApp, in person) without going through email." Style upgraded from `ghost` to `outline` so it reads as a peer action to Send invitation / Send reminder instead of a tertiary affordance.
- **Surface coverage:** SigningProgress is the single canonical signing-progress component (used by ActiveEoiCard / InterestReservationTab / InterestContractTab / DocumentDetail / DocumentDetail signers section via #67 doc-detail polish). One fix lands everywhere.
- **Alternatives considered + rejected:**
  - Always show "Copy link" enabled and silently fail when URL is missing — rejected; reps would copy emptystring and ship a broken link in chat.
  - Show "Copy link" only after invitation is sent — rejected because the design comment (line 388–393) explicitly calls out reps wanting to preview / share the URL BEFORE the formal email goes out.
- **Effort:** ~10 min for the condition flip + tooltip; ~0 min for the cross-surface coverage because SigningProgress is shared.
- **Cross-refs:** the prior session shipped the Documenso v2 distribute-response field plumbing that populates `signingUrl` (`c4450dd` lineage). This finding is the UI follow-up.
- **Acceptance criteria:** every pending signer row in every document signing surface shows BOTH a Copy link button (disabled when URL not yet issued, tooltip explaining why) AND the appropriate Send invitation / Send reminder primary action.

### UploadForSigningDialog — "Recipient" label is too thin for a load-bearing choice

- **`SHIPPED locally (not yet committed)`**
- **Files touched:** _src/components/documents/upload-for-signing-dialog.tsx_ (FieldSidePanel, ~line 1399).
- **React-grab anchor:** `<label class="font-medium pee...">Recipient</label>` in `Label` in `FieldSidePanel` at `upload-for-signing-dialog.tsx:1376:4`.
- **Symptom:** the FieldSidePanel — the right-hand "Field properties" panel that opens when the rep selects a placed signature/text/date/checkbox field on the PDF — labels its signer-assignment dropdown with the single bare word `Recipient`. The user flagged this as non-descriptive: the field is **load-bearing** because it determines which of the document's recipients will see and fill that specific field at signing time. A wrong selection sends the field to the wrong person; a confused rep skips the step and Documenso defaults to the first recipient. "Recipient" by itself doesn't communicate any of that — it reads like a passive metadata label, not an active assignment choice.
- **Root cause:** the panel was scaffolded as a generic Type / Recipient / Value triplet without UX copy. The Select dropdown DOES populate correctly (recipients come from the dialog's `recipients` prop with `#order Name/Email` formatted), so the wiring is fine — the gap is purely the label + a missing explainer.
- **Fix applied:**
  1. Label text changed from `Recipient` → `Assign this field to`. Active verb makes it clear this is a deliberate choice the rep is making, not a metadata read-out.
  2. Helper paragraph added below the dropdown: "Whoever is selected here is the only person who will see and fill this field when the document is sent for signing." Plain English, explicit consequence.
- **Alternatives considered + rejected:**
  - Renaming to "Signer" alone — rejected because the document recipient list can include CC / approver roles that aren't strictly signers, and "Signer" implies they sign.
  - Using a per-recipient color-coded chip strip instead of a dropdown — rejected because reps frequently need to assign 10+ fields across multiple recipients in dense forms; a dropdown is faster than chips at that volume. Could be a future enhancement bundled with field-placement keyboard shortcuts.
- **Effort:** ~5 min (the fix itself). The rejected color-coded-chip alternative would be ~2h.
- **Cross-refs:** prior session shipped `c4450dd` (field metadata panel + payload extension); this is a follow-up polish on the same panel.
- **Acceptance criteria:** the FieldSidePanel's recipient-assignment row reads "Assign this field to" with the helper sentence below, and the dropdown still populates the document's recipients in signing-order with `#order Name/Email` formatting.

### Recommender card — Heat badge needs explainer tooltip

- **`SHIPPED locally (not yet committed)`** — _src/components/interests/berth-recommender-panel.tsx_ (RecommendationCard Heat badge).
- **Symptom:** "Heat 81" badge rendered with no explanation of what the number means. The tier badge next to it already has a Popover; the heat badge was a plain span.
- **Fix applied:** badge converted to a Popover trigger. Popover surfaces the headline ("Heat score · 81 / 100"), explains the formula in plain English ("how warm this berth is for a re-pitch — recency × furthest stage × interest count × EOI count"), shows the four component scores from `rec.heat.*`, and notes that admins tune the weights in Admin → Recommender.

### Recommender card — area letter duplicates mooring number

- **`SHIPPED locally (not yet committed)`** — _src/components/interests/berth-recommender-panel.tsx_ (RecommendationCard header).
- **Symptom:** card rendered `E1` followed by a separate "E" label. Mooring number already carries the area letter as a prefix (canonical `^[A-Z]+\d+$` per CLAUDE.md), so the standalone area letter was pure visual noise — same complaint as the BerthPicker fix earlier this session.
- **Fix applied:** removed the area-letter span from RecommendationCard.

### Recommender tier contradicts berth status

- **`SHIPPED locally (not yet committed)`** — _src/lib/services/berth-recommender.service.ts:223_ (`classifyTier`) + _src/components/interests/berth-recommender-panel.tsx_ (card render).
- **Fix applied:** `TierInputs.status` propagated end-to-end. `classifyTier` now collapses the contradiction: `status='sold'` → D, `status='under_offer'` (with or without active interest rows) → C, otherwise existing rules. `RawRow.status` already feeds in via `classifyTier(r)`.
- **Symptom:** berth D39 shows both `Under Offer` (status pill) AND `Open` (recommender tier). The tooltip definition contradicts itself: "Open: never had an interest, ready for new prospects."
- **Root cause:** `classifyTier` only reads from `interest_berths` aggregates (active count / lost count / max active stage). A berth whose `berths.status` column says `Under Offer` — set manually by an admin, imported from NocoDB, or left over from a stale row — has zero entries in interest_berths if no active interest is currently driving the status, so the tier classifier returns A (Open). The two signals come from different sources and aren't reconciled.
- **Fix:** add `berthStatus` to `TierInputs` and bias `classifyTier`:
  - If `berthStatus === 'Sold'` → return `'D'` (treat sold the same as a late-stage active interest, since the rep should treat it as effectively closed; we still surface it as a backup option).
  - If `berthStatus === 'Under Offer'` AND `activeInterestCount === 0` → return `'C'` (someone is on it according to the public map even if interest_berths doesn't know who). The competing-interest chip from the previous finding then surfaces who that someone is.
  - Otherwise fall through to existing rules.
- **Alternative considered:** filter Under Offer / Sold berths out of recommendations entirely. Rejected because reps DO use the recommender to surface backup options for "this might fall through" planning. The tier should just match the reality.
- **Effort:** ~30–45 min (TierInputs widen + plumb berth status through the aggregator query + adjust the tooltip copy so "Open" / "Active interest" labels stay coherent).

### Berth occupancy info — surface competing interest on every non-available status

- **`SHIPPED locally (not yet committed)`** — _src/components/berths/berth-occupancy-chip.tsx_ (shared chip) + adopted in _linked-berths-list_ (LinkedBerthRowItem) + _berth-recommender-panel_ (recommendation cards) + _interest-berth-status-banner_ (deal-level banner).
- **Fix applied:** new `<BerthOccupancyChip berthId excludeInterestId={currentInterestId} />` reuses `/api/v1/berths/[id]/active-interests`. Renders inline on every non-available status surface (linked-berths list, recommender cards, deal banner). Hides when the only competing interest is the current one.
- **React-grab anchors:** `<span>Under Offer</span>` in StatusPill in LinkedBerthRowItem; same pill in the recommender card body.
- **Symptom:** anywhere a berth's status renders as "Under Offer" / "Sold" / "Reserved" the rep currently has no idea WHO is responsible for that status. They have to navigate to the berth detail page (or guess) to find the competing interest or the closed-deal client.
- **Fix:** reuse the existing `/api/v1/berths/[id]/active-interests` endpoint (shipped for the columns popover + `InterestBerthStatusBanner`) and surface the top competing interest inline on every non-available status surface. Show client name + stage pill + a link to the competing interest detail. Hide when the only competing interest is the current one (self-conflict makes no sense to flag).
- **Recommended implementation:** extract a small `<BerthOccupancyChip berthId={...} excludeInterestId={currentInterestId} />` component that runs the query, renders the chip when there's something to surface, and shares behaviour across:
  - `LinkedBerthRowItem` (per linked berth on the interest detail)
  - `BerthRecommenderPanel` recommendation card body (per recommended berth)
  - `InterestBerthStatusBanner` (deal-level banner — already does this; migrate to use the shared chip so the rendering stays consistent)
  - `berth-columns.tsx` active-interests popover (already exists; keep its richer multi-row popover, but reuse the data fetcher).
- **Effort:** ~1.5–2h. Single new shared component + 3 call-site adoptions + the deal-level banner migration. Closes the "who owns this berth right now" gap platform-wide in one pass.

### NotesList source badge — clickable navigation to source entity

- **`SHIPPED locally (not yet committed)`** — _src/components/shared/notes-list.tsx_.
- **Symptom:** the "Yacht · Test Yacht" badge on aggregated notes (e.g. on a client's Notes tab, surfacing a note left on their linked yacht) was a plain `<span>` — no way to pivot from the note to the source entity without leaving the page.
- **Fix applied:** badge is now a `<Link>` to the source entity's detail page when `sourceId` is available (clients/companies/yachts/interests/residential variants all covered). New `sourceLinkFor(portSlug, source, sourceId)` helper centralises the URL mapping. `stopPropagation` keeps any outer row-click handler from interfering.

### Notes tab header count doesn't aggregate

- **`SHIPPED locally (not yet committed)`** — _src/lib/services/notes.service.ts_ (new `countFor{Client,Yacht,Company}Aggregated`) + _clients.service.ts_, _yachts.service.ts_, _companies.service.ts_ (wired into `getById` responses) + _yacht-tabs.tsx_, _company-tabs.tsx_ (badge prop).
- **Fix applied:** new symmetric-reach count helpers in `notes.service.ts` mirror the existing `listFor*Aggregated` joins. Client tab counts client + interest + yacht (owned) + company (active membership) notes; yacht tab counts yacht + polymorphic-owner + linked-interest notes; company tab counts company + owned-yacht + their-interest notes. `getYachtById` / `getCompanyById` now return `noteCount`; tab definitions render the badge.

### Admin toggle to disable Tenancies entirely

- **`PARTIALLY SHIPPED`** — backend exists, admin UI missing. _src/lib/services/tenancies-module.service.ts_ (`disableTenanciesModule(portId)` + companion `isTenanciesModuleEnabled` + the `tenancies_module_enabled` setting) + _src/app/api/v1/admin/tenancies-module/\*_.
- **Symptom / user ask:** rep is in "pure sales mode" — doesn't want Tenancies spilling into the UI yet. Wants an admin-level switch to turn the module off so the sidebar entry / entity tabs / dashboard widgets / top-level page all hide.
- **Status:** the platform already supports this (per docs/tenancies-design.md §"Platform-wide module-enabled rule"). What's MISSING is the admin Operations toggle in the settings UI: a Switch wired to `POST /api/v1/admin/tenancies-module/enable` / `POST .../disable`, with the disable path showing a confirmation modal ("This will hide N existing tenancies — data is preserved but invisible until re-enabled. Continue?"). Per the design doc the helper copy reads: "When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform doesn't model the occupancy record."
- **Fix:** add the Switch to `src/app/(dashboard)/[portSlug]/admin/operations/page.tsx` (or wherever the operations settings live), wire to the existing endpoints, gate behind `admin.manage_settings`. ~45 min.

### Activity feeds — generic "updated this record" hides real changes

- **`PARTIALLY SHIPPED locally (yacht transfer only)`** — _src/lib/services/yachts.service.ts:215_ (transferOwnership) + _src/components/shared/entity-activity-feed.tsx:26_ (ACTION_VERBS) + every other service that writes audit_log entries with `action: 'update'` and no `fieldChanged`.
- **Symptom:** EntityActivityFeed reads audit_logs and falls back to "X updated this record" when the row has no `fieldChanged`. Major lifecycle events (yacht owner transfer, interest stage transitions, berth status flips, document state changes) write that exact generic row, so the feed loses ALL useful detail — defeats the audit-trail purpose.
- **Yacht-transfer fix shipped:** `transferOwnership` now resolves both the old + new owner names (client → fullName / company → name), writes the audit row with `action: 'transfer'`, `fieldChanged: 'owner'`, `oldValue: oldOwnerName`, `newValue: newOwnerName`, plus reason/notes in metadata. EntityActivityFeed's `ACTION_VERBS` gains `transfer → 'transferred'`. Result: "Matt transferred owner to Jane Smith" instead of "Matt updated this record."
- **Still open — sweep across every audit-log writer:** every other service emitting `action: 'update'` with no `fieldChanged` (or with an object as `newValue`) needs the same treatment. Pattern: discrete action verb + named field + human-readable old/new values. Candidates surfaced in earlier audits: interest stage transitions, berth status flips, document send / sign / cancel events, eoi auto-cancel, tenancy activate / end / transfer, payment record/delete. Each is ~10min of service-layer surgery; the bulk is the sweep.

### Activity feed UI — standardize across every entity surface

- **`OPEN`** — _src/components/shared/entity-activity-feed.tsx_ (the shared primitive) + every page that mounts an activity feed (client, interest, yacht, berth, company, tenancy, document).
- **Symptom:** the user judges the client + interest activity feeds as the best-presented; other surfaces feel inconsistent. The shared `EntityActivityFeed` IS the same component across consumers, so the visual difference must be in (a) which surfaces still use a bespoke per-entity feed rather than the shared one, or (b) which surfaces pass which props (filters, empty-state copy, session-grouping window).
- **Fix:** audit every place an activity feed renders. Anything that's bespoke gets migrated to the shared `EntityActivityFeed`. Anything that already uses the shared component but passes weak props (no filter dropdowns, no session collapsing) gets brought up to the client/interest baseline. Bundle with the audit-log content sweep above so the entries the feed renders are also comprehensive.

### CompanyPicker — empty on open

- **`SHIPPED locally (not yet committed)`** — _src/app/api/v1/companies/autocomplete/handlers.ts_ + _src/lib/services/companies.service.ts:303_.
- **Symptom:** CompanyPicker popover opens empty even though the port has companies on file. Has to type something before any options surface.
- **Root cause:** the autocomplete handler returned `{ data: [] }` immediately when `q` was empty; the picker fires its first query with `debounced=''`, so the list was always empty on first open.
- **Fix applied:** empty `q` now returns the 10 most-recently-updated companies for the port (still capped to 10, matching the typed-search path). Non-empty `q` keeps the existing ilike-match.

### Yacht transfer dialog — drop "atomic" from copy

- **`SHIPPED locally (not yet committed)`** — _src/components/yachts/yacht-transfer-dialog.tsx:136_.
- **Symptom:** dialog description says "The change is auditable and atomic." — "atomic" is engineering jargon, doesn't mean anything to a normal user.
- **Fix applied:** rewrote to "The change is logged in the audit history." Same meaning, no jargon.

### ClientTenanciesTab — pending tenancies invisible

- **`SHIPPED locally (not yet committed)`** — _src/lib/services/clients.service.ts:415_.
- **Symptom:** rep creates a tenancy via "Create tenancy" (status `pending`), sidebar Tenancies entry surfaces (lazy module flip works), but the client detail's Tenancies tab shows the empty state. Same for any pending tenancy auto-created from a signed Reservation Agreement webhook before the rep confirms activation.
- **Root cause:** `clients.service.getById` filters `activeTenancies` to `status === 'active'` only. Pending rows fall outside that filter and never reach the tab.
- **Fix applied:** filter widened to `inArray(status, ['pending', 'active'])`. The `TenancyList` component already renders a status pill per row so the rep distinguishes pending from active without a section split.

### TenancyList rows — not clickable to tenancy detail

- **`SHIPPED locally (not yet committed)`** — _src/components/tenancies/tenancy-list.tsx_.
- **Symptom:** rows in the Tenancies sections (client tab, berth tab, yacht tab, top-level `/tenancies`) carry per-cell links for berth / client / yacht but no way to open the tenancy itself. Reps had to click the contract link or hunt for an edit affordance.
- **Fix applied:** rows now navigate to `/{portSlug}/tenancies/{id}` on click. Inner links/buttons (BerthLink, ClientLink, YachtLink, "View contract") still fire their own behaviour because the click handler bails when the target is inside an `<a>` or `<button>`. Keyboard support: Enter/Space on the row also opens detail.

### BerthPicker — area suffix duplicates the group heading

- **`SHIPPED locally (not yet committed)`** — _src/components/shared/berth-picker.tsx:141_ (labelFor).
- **Symptom:** every option rendered as `Berth A1 · A`, `Berth B5 · B` etc. The mooring number is already prefixed with the area letter, and the dropdown groups options under area-letter headings. The trailing ` · A` reads as visual noise.
- **Fix applied:** dropped the area suffix from `labelFor` — rows now read `Berth A1`, `Berth B5`. Group heading still carries the area context. Same fix lands across every consumer of BerthPicker (tenancy create / renew / transfer dialogs, interest form, linked-berths add, etc.) because the label is centralized.

### Tag chips missing wherever StageStepper renders

- **`SHIPPED locally (not yet committed)`** — _src/components/clients/client-pipeline-summary.tsx_ + _src/components/clients/client-interests-tab.tsx_.
- **Fix applied:** every StageStepper call site (Overview top-deal block, Overview interest list, Interests-tab row item, Interests-tab detail panel) renders a tag-chip strip under the stepper. ClientInterestRow type carries `tags?: Array<{ id, name, color }>` and the interests list endpoint resolves the join in a single batch.
- **React-grab anchor:** `<div class="flex-1 truncate...">Qual.</div>` in StageStepper in InterestRowItem in ClientInterestsTab.
- **Symptom:** the InterestRowItem cards show berth label + stage badge + stepper, but no tag chips. Tags are first-class on interests everywhere else (detail page, list view) — the same chips should follow the StageStepper everywhere it appears so reps see "Hot lead / VIP / Returning client" context at a glance without drilling in.
- **Fix:** (a) extend `ClientInterestRow` with `tags?: Array<{ id, name, color }>` and surface from `useClientInterests` (`/api/v1/interests?clientId=X`). (b) Render a small tag-chip strip just above or below the StageStepper in InterestRowItem + every other StageStepper call site (currently `client-interests-tab.tsx:88, 263`, `client-pipeline-summary.tsx:224, 340`). (c) Cap to ~3 chips with a "+N" overflow indicator so long tag lists don't blow up the row height.

### New-document "Upload file" — unclear where the file lands

- **`SHIPPED locally (not yet committed)`** — _src/components/documents/new-document-menu.tsx_ (Upload file dialog's `onUploadComplete`).
- **Fix applied:** per-file completion now emits a `toast.success('Uploaded <filename>')` with an action link. When the upload happened under an entity (clients/companies/yachts) the action navigates to that entity's detail page; otherwise it opens the destination folder via `/documents?folderId=…`. Still deferred (lower priority): naming the destination folder verbatim in the pre-upload dialog description.

### Recent files — no link to folder or attached entity

- **`SHIPPED locally (not yet committed)`** — _src/components/documents/hub-root-view.tsx_ + _src/lib/services/files.ts_ (`listFiles`).
- **Fix applied:** each row in the Recent files panel shows a folder chip (linking to `/documents?folderId=…`) and an entity badge (Interest / Client / Yacht / Company → entity detail page). `listFiles` already resolves `folderName / clientName / yachtName / companyName / interestSummary` in a single batched lookup so no N+1 cost.
- **React-grab anchor:** `<h3 class="flex items-cent...">Recent files</h3>` in HubRootView.
- **Symptom:** each recent-file row only shows filename + size + date; the rep has to remember which client / interest the file belongs to. No CTA to jump into the parent folder either.
- **Fix:** extend row payload with `{ folderId, folderName, clientId, clientName, interestId, interestBerthLabel }`. Render a small badge column showing the attached entity (client name or interest's berth label, like the EntityFolderView pattern already shipped). Right-hand action gains an icon button "Open folder" that navigates to the folder view in Documents Hub.

---

## Bucket 2 — Medium (15 min – 2 h)

### Supplemental-info form — no port branding, no logo on top

- **`SHIPPED locally (not yet committed)`** — _src/app/public/supplemental-info/[token]/page.tsx_ + _src/components/shared/branded-auth-shell.tsx_ + _src/lib/services/supplemental-forms.service.ts_ (loadByToken).
- **Fix applied:** `loadByToken` now returns `port: { name, logoUrl, backgroundUrl }` via `getPortBrandingConfig(token.portId)`. Page passes that directly to `BrandedAuthShell` via the explicit `branding` prop so the logo + backdrop render regardless of the route-group context.

### Supplemental-info form — extends edge-to-edge on long forms

- **`SHIPPED locally (not yet committed)`** — _src/components/shared/branded-auth-shell.tsx_.
- **Fix applied:** added a `width?: 'sm' | 'md'` prop. `'md'` widens the card to `max-w-xl` and swaps the `fixed inset-0` viewport pin for a normal `min-h-dvh` page scroll, so a 20+ field form scrolls naturally on mobile instead of clipping under the rubber-band cap. Login surfaces stay on `'sm'` (default) with the original pinned-and-centered shell.

### Supplemental-info form — address fields incomplete

- **`SHIPPED locally (not yet committed)`** — _src/app/public/supplemental-info/[token]/page.tsx_ + _src/app/api/public/supplemental-info/[token]/route.ts_ + _src/lib/services/supplemental-forms.service.ts_.
- **Fix applied:** form now exposes street + city + region/state + postal code + country as separate inputs, mirroring the `client_addresses` shape. `loadByToken` returns the existing values for prefill; the API schema accepts the new fields; `applySubmission` diffs + writes them per-column with field-history entries.

### Supplemental-info form — no context about where details land

- **`SHIPPED locally (not yet committed)`** — _src/app/public/supplemental-info/[token]/page.tsx_.
- **Fix applied:** added the port name as an eyebrow above the title ("PORT NIMARA") and a clarifying line in the intro: "Submissions go straight to the team handling your application." The success state also references the port name explicitly.

### Marketing-site form parity — primary surface lives on the website

- **`OPEN`** (cross-repo) — _docs/marketing-site-followups.md_ for the spec; CRM keeps the `/public/supplemental-info/[token]` route as fallback.
- **Symptom / direction:** the marketing site should host the public-facing supplemental-info form (and any other public client forms, e.g. the EOI pre-flight intake) so the polish matches the rest of the public surface. The CRM-hosted page stays as the operator-safe fallback if the marketing site is down or not pointed at.
- **Fix:** document the API contract in `docs/marketing-site-followups.md` (route, payload shape, prefill response, submission schema, token expiry behaviour) so the marketing-site team can build the equivalent. Per-port hardcoded form layouts are fine on the marketing-site side; the CRM API stays generic.

### Interest OverviewTab — inherit empty fields from client + visually denote

- **`SHIPPED locally (not yet committed)`** — _src/components/interests/interest-tabs.tsx_ (OverviewTab + EditableRow).
- **Fix applied:** EditableRow gains an `inheritedFrom?: 'client' | 'yacht' | 'company'` prop that renders a small "from client" / "from yacht" pill next to the label. Wired on the Email + Phone rows so reps know edits propagate to the client-level contacts table. Yacht-dimension inheritance was already in place via the `yachtDimensions` payload + per-axis "from yacht" pill in the desired-dimensions block; both inheritance signals now use the same visual language.
- **React-grab anchor:** `<div class="space-y-1" />` in OverviewTab, inside the TabsContent presence wrapper.
- **Symptom:** the OverviewTab shows interest-level fields that, when empty, render as " - ". If the client (or linked yacht for dimensions) already has those details on file, the rep has to navigate to the client / yacht to see them. Adds friction + risks reps re-asking the client.
- **Fix:** when an interest field is null but the client/yacht has it filled, render the inherited value with a small visual cue (e.g. italic + a "from client" or "from yacht" pill). Editing in place should write to the interest's own column (override). Specific candidates:
  - Berth requirements (desiredLengthFt/widthFt/draftFt) → fall back to linked yacht's lengthFt/widthFt/draftFt.
  - Email/phone — already shown via `ClientChannelEditor` which reads client-level; the inheritance is implicit there but no visual indicator exists for "this came from client primary contacts."
  - Address / country — interest has no address column; if shown on Overview, it's a pure read of the client's primary address (visual indicator helps reinforce that editing here updates the CLIENT, not just this deal).
- **Open question:** should editing an inherited dimension write to the interest (override, deal-specific) or to the yacht (correct the yacht record)? Default proposal: write to the interest (override pattern) and offer a follow-up CTA ("Update yacht record too?").

---

## Bucket 3 — Features / larger (> 2 h)

### Documenso rejection reason — pull through + surface to the rep

- **`SHIPPED locally (not yet committed) — backend; UI surfacing queued`**
- **Files touched:**
  - _src/app/api/webhooks/documenso/route.ts_ (`DocumensoRecipient` type extended with `rejectionReason` + `declineReason`; DOCUMENT_REJECTED / DOCUMENT_DECLINED handler now coalesces the two field names and passes through).
  - _src/lib/services/documents.service.ts_ (`handleDocumentRejected` signature gains `rejectionReason?: string | null`; `document_events.eventData` stores it; audit log metadata carries it; the in-CRM notification description quotes it inline, truncated at 120 chars with full reason still in the audit row).
- **User's question:** "are we able to pull through the rejection reason through the API if a signer rejects the document through documenso? if so we need to pull it through and append it."
- **Answer:** yes — Documenso sends the cleartext reason on the recipient object (`rejectionReason` on v2; some 1.x payloads use the legacy `declineReason`). Up to this fix we were ignoring both. Now coalesced + persisted + surfaced.
- **Where the reason now appears (after this fix):**
  1. `document_events` row → `eventData.rejectionReason` (the audit timeline can render it).
  2. `audit_logs` row → `metadata.rejectionReason` (admin's audit-log viewer surfaces it).
  3. In-CRM rep notification → inline in the description quoted in ASCII quotes, truncated to 120 chars so the bell tile doesn't wrap awkwardly. Example: `matt@letsbe.solutions declined to sign: "The deposit amount needs to be £20k not £30k" — review and regenerate.`
- **Still queued (UI surfacing):** EOI tab + InterestEoiTab status banner should also render the rejection reason inline below the "EOI declined" headline. Right now the banner just says rejected without surfacing the why. ~30 min to wire — query the latest `document_events` row of type=`rejected` for the active EOI and pluck `eventData.rejectionReason`. Bundle with the next round of EOI-tab polish.
- **Cross-ref:** the broader "Activity feed comprehensive copy" finding above — both are about pulling raw signal out of audit_logs / document_events and rendering it as actionable copy instead of generic "updated this record" / "EOI declined." Pattern: every domain event should carry domain-meaningful detail through to the UI.

### Documenso rejection — UI didn't reflect rejected state; poller fallback was missing the REJECTED branch

- **`PARTIALLY SHIPPED locally (poller fixed; webhook URL auto-update + admin health-check queued)`**
- **Confirmed root cause (per user):** Documenso webhooks were configured to a stale cloudflared tunnel URL (quick-tunnels rotate hostnames on restart). Documenso was POSTing into a dead host. The CRM never received the rejection event. User confirmed: "the webhooks aren't working because they're a cloudflare tunnel link that is set in the crm but no longer works".
- **Secondary root cause (discovered while fixing):** the existing `signature-poll` BullMQ job runs every 5 minutes via `src/lib/queue/scheduler.ts:21` and is the documented fallback for missed webhook deliveries — but it **did not handle the REJECTED / DECLINED path at all.** It only reconciled SIGNED (recipient), COMPLETED (document), and EXPIRED (document). A rejected document polled by this job saw no matching branch and exited silently. So even with the polling fallback running, rejections were invisible to the CRM. User reasonably asked: "shouldn't the API be polling for updates to signatures/document stuff in the absence? Is the system not checking if the webhook works, or is there no way to do so?"
- **Files touched (this fix):**
  - _src/lib/services/documenso-client.ts:157_ (`normalizeDocument`) — recipient shape now coalesces `rejectionReason` ?? `declineReason` and surfaces it on every poller / direct-fetch consumer.
  - _src/lib/services/documenso-client.ts:213_ (`DocumensoDocument.recipients[]`) — gains optional `rejectionReason?: string`.
  - _src/jobs/processors/documenso-poll.ts_ — new `else if` branch for `remoteDoc.status === 'REJECTED' | 'DECLINED'`. Finds the rejecting recipient, plucks the reason, hands off to `handleDocumentRejected` with the same shape the webhook receiver uses — so `document_events`, audit log, notification, and UI all converge on identical state regardless of delivery path.
  - _src/lib/services/documents.service.ts:1920_ (`handleDocumentRejected` — already-extended in the earlier rejection-reason finding) — accepts `rejectionReason?: string | null`, stores on `document_events.eventData`, surfaces in the rep notification description, persists in audit log metadata.
  - _src/app/api/webhooks/documenso/route.ts_ (already-extended earlier this turn) — DOCUMENT_REJECTED / DOCUMENT_DECLINED handler coalesces the reason and passes through.
- **Result of this fix:** even with a broken tunnel, the rejected document will converge to `status='rejected'` within 5 minutes of the next `signature-poll` job tick. The rep gets the notification, the EOI tab status pill flips, audit log carries the rejection reason. Webhook is now an OPTIMISATION (sub-second), not a CORRECTNESS REQUIREMENT.
- **Still queued (higher-value follow-ups):**
  1. **Auto-update Documenso's webhook URL on tunnel restart.** `./scripts/tunnel-url.sh --copy` already prints the URL; extend it to also POST to Documenso's webhook-update API endpoint using the same API key the CRM uses for envelope creation. One command rotates the URL on every dev session. Add a LaunchAgent post-start hook so this happens automatically when the tunnel-service restarts.
  2. **Admin "Webhook health" page.** New page at `/admin/integrations/webhooks` that surfaces: last-received timestamp per webhook event type (so a multi-day gap is visible), count of webhooks received in the last 24h vs documents created in the same window (the ratio should be ~1:1 in a healthy port), a "Test webhook delivery" button that posts a synthetic test event and waits for the round-trip. ~3–4h.
  3. **Periodic divergence alarm.** Cron job (separate from `signature-poll`): if more than X documents are stuck in `'sent'` for > Y hours, fire an alert to super admins so they investigate webhook / Documenso config. ~1h once the alert infra is settled.
  4. **Document the "re-paste tunnel URL into Documenso after every tunnel restart" gotcha in CLAUDE.md** until the auto-PATCH lands. ~5 min.
- **Why polling alone isn't enough long-term:**
  - Latency: 5-min worst case until the CRM converges. Reps watching for a fresh signature don't want to wait 5 minutes.
  - Cost: per-poll `getDocument` call per in-flight doc per 5 min × N ports = noticeable Documenso API traffic at scale.
  - Webhooks remain the right primary path; polling is the safety net. Both should work.
- **How the user can verify the fix right now:**
  - Run `./scripts/tunnel-url.sh --copy`, paste the URL into Documenso webhook settings (Documenso → Settings → Webhooks → edit the existing one → paste new URL → save). The webhook is now reachable for the next test.
  - Alternatively (without fixing the tunnel), wait up to 5 minutes — the poller will pick up the existing rejected doc and reconcile it. Watch the EOI tab; status pill should flip from AWAITING SIGNATURES to REJECTED.
- **Cross-refs:**
  - The "Documenso upload comprehensive audit" finding (Bucket 3 above) — bundle with that audit since both are about Documenso ↔ CRM state convergence under failure modes.
  - The "Documenso rejection reason — pull through" finding above — same chain of changes; the poller fix completes the rejection-reason-everywhere arc.
- **Open questions for the user:**
  1. **Should the auto-PATCH of Documenso's webhook URL on tunnel restart happen unconditionally**, or behind a feature flag (`DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1`) so prod ports can't accidentally have their webhook URL rotated by a stale dev script? My recommendation: env-flag-gated.
  2. **What should the admin Webhook Health page do for ports with NO webhooks ever received?** Render a "not yet tested" empty state, or auto-fire a synthetic test on first page load? Default proposal: explicit "Test now" button — surprise-auto-firing webhooks on a fresh admin visit is wrong.

### Documenso signing order — does template's SEQUENTIAL win or does CRM override?

- **`ANSWER + clarifying fix queued`**
- **User question:** "is the signing order we designate overridden by the template signing order set in the documenso app when I make a template?"
- **Files inspected:**
  - _src/lib/services/documenso-client.ts:462-499_ (template-use → envelope-update post-create flow).
  - _src/lib/services/documents.service.ts:813_ (`docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}` spread on EOI generate call).
  - _src/lib/services/port-config.ts_ (getPortDocumensoConfig returns `signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null`).
- **Answer:** **the CRM's per-port `documenso_signing_order` setting overrides the template's stored signing order — but only when the port setting is explicitly set.** Mechanism:
  - `/template/use` creates the envelope from the template. Documenso v2's template-use endpoint **silently drops the `meta` field on the request body** — signingOrder/subject/message/redirectUrl all inherit from the template's stored defaults. (See the comment at documenso-client.ts:464.)
  - The CRM then patches `/envelope/update` while the envelope is still DRAFT to apply per-port overrides. This update _can_ set signingOrder.
  - At documenso-client.ts:472-476 the update only includes `signingOrder` (and the other meta fields) when the value is non-empty. If the port's `documenso_signing_order` setting is empty/null, the update skips that field and the **template's stored value (SEQUENTIAL in your case) is preserved.**
  - At documents.service.ts:813 the signingOrder is only PASSED to the create call when truthy. Same logic — empty port setting means template wins.
- **Implication for the user's port:** if the EOI currently shows "Concurrent" but the template is SEQUENTIAL, your port's `documenso_signing_order` setting is set to `PARALLEL` (overriding the template). Check at Admin → Documenso → Behavior → signing order. Either flip it to SEQUENTIAL (forces sequential regardless of template) or clear it to `null` (defers to whatever the template specifies, which would honour your SEQUENTIAL template).
- **Suggested UX fix (capture as `OPEN`):** the admin settings form for `documenso_signing_order` should offer **three** values, not two: `SEQUENTIAL`, `PARALLEL`, and `Use template default` (the empty/null state). Today it's a binary toggle that hides the "defer to template" option. A rep configuring per-port settings can't easily express "I want the template to win" without knowing to leave the field blank.
- **Cross-refs:** ties into the Automate Signing finding directly below — automation behaviour DEPENDS on signing order semantic, so they should ship in the same wave.

### Automate signing — single button that cascades invites + emails the completed doc (REFINED with signing-order awareness)

- **`SHIPPED locally (not yet committed)`** — committed earlier this UAT round as commit `fe5f98d` (`feat(automate-signing): one-click invitation kickoff + auto cascade + completion broadcast`).
- **Where it lives:** `src/lib/services/signing-automation.service.ts` orchestrates the kickoff + cascade. `documents.automation_mode` column tracks `'manual' | 'sequential_auto' | 'concurrent_auto'` (migration `0088_documents_automation_mode.sql`). Webhook handler in `src/app/api/webhooks/documenso/route.ts` reads automation mode on each recipient-signed event and fires the next invite when in `sequential_auto`.
- **Files implicated (once built):**
  - _src/components/documents/active-eoi-card.tsx_ (new "Automate signing" button + state visualisation).
  - _src/lib/services/documents.service.ts_ (new `automateSigning(documentId, portId)` orchestrator).
  - _src/lib/services/documenso-client.ts_ (already has `sendDocument` + `sendReminder`; may need `setSigningOrder('SEQUENTIAL')` mid-flight if the doc wasn't created sequential).
  - _src/app/api/webhooks/documenso/route.ts_ → `handleRecipientSigned` (today only updates the row; needs a branch that fires the NEXT signer's invite when the envelope is in "automated" mode).
  - _src/lib/db/schema/documents.ts_ — new column `documents.automation_mode: 'manual' | 'sequential_auto' DEFAULT 'manual'`.
  - _src/lib/email/templates/_ — new template `signing-completed-recipient-bundle.tsx` for the all-done broadcast with signed PDF attached (already 80% there — `compose-completion-email` route exists per `document-detail.tsx:217`).
- **React-grab anchor:** `<section class="rounded-xl bord..." />` in `ActiveEoiCard` in `InterestEoiTab`.
- **User's request (verbatim):** "there should also be something like an 'Automate Signing' button where it sends out an auto invite to the signers in order one after the other as they sign, then send them all a confirmation email with the signed document attached when done."
- **Proposed feature spec (two-mode):**
  1. **New button on ActiveEoiCard:** "Automate signing" — visible when (a) the doc has ≥2 signers, (b) status is `draft` (Documenso has the envelope but no invite has gone out yet), (c) the rep has `documents.send` permission. Same conditions as the existing per-row "Send invitation" CTA but operates over the whole flow.
  2. **On click:** the dialog branches based on the document's signing order (which the CRM reads from the envelope via `getDocument` or persists locally on `documents.signing_order` at create time):
     - **Concurrent / PARALLEL signing order:** confirmation modal explains "All N signers will receive the invitation now. As each signs, you'll see their progress in real time. When everyone has signed, every recipient gets the completed PDF by email." Submission fires ALL signer invitations in parallel (single bulk dispatch) and sets `documents.automation_mode='concurrent_auto'`. The webhook completion handler still fires the final broadcast email — same as sequential mode below.
     - **Sequential / SEQUENTIAL signing order:** confirmation modal explains "Documenso will route this in order. First we'll invite {firstSigner.name}. As each signer completes, the next invite fires automatically. When everyone has signed, every recipient gets the completed PDF by email." Submission fires only the first signer's invitation and sets `documents.automation_mode='sequential_auto'`. Webhook handler fires next-in-order on each `recipient_signed` (logic below).
  3. **Webhook side (sequential mode only):** in `handleRecipientSigned`, after the existing row update, check the parent doc's `automation_mode`. If `sequential_auto` AND there's a next-in-order signer with `invitedAt=NULL` AND envelope status isn't completed, fire that signer's invitation. Concurrent mode skips this entirely (everyone already invited). Use the existing token + branded-invite path so the email is identical to a manually-fired invite.
  4. **On completion** (`handleDocumentCompleted`) — shared across both modes: if `automation_mode` is `concurrent_auto` OR `sequential_auto`, queue the existing `composeCompletionEmail` route logic to send the signed PDF to every recipient (signers + CCs + approvers). Stays decoupled from the user-driven `email-completion` flow that already exists for manual mode.
  5. **UI state during automation (mode-aware):**
     - **Sequential:** ActiveEoiCard shows an "Automating · signer N of M" banner.
     - **Concurrent:** banner reads "Automating · all N signers invited · 0 of N signed" and updates as signatures land.
     - **Both modes:** per-row layout collapses to a status badge + the existing Copy link button (so reps can still manually share if they want a parallel channel).
     - **Both modes:** A "Pause / Revert to manual" affordance lets the rep stop auto-firing mid-flow (set `automation_mode='manual'`).
  6. **Why distinguish concurrent vs sequential automation:** user noted that for concurrent, automation is just "send invites at once" — the cascade-as-they-sign logic only applies to sequential. Spec must NOT force a concurrent doc into a sequential cascade just because the rep clicked Automate. The signing order is preserved from the envelope; automation respects it.
- **Why this matters:** today the rep has to babysit a multi-signer doc: send invite #1, watch for webhook, send invite #2, repeat. For a 4-signer Reservation Agreement (common case per recent UAT screenshot) that's 4 manual button clicks across hours/days. Automation closes the gap between "Documenso supports sequential signing" and "the rep gets a one-click 'set it and forget it' workflow."
- **Effort:** ~6–8h end-to-end.
  - ~30 min schema migration + Drizzle type update for the new column.
  - ~1h orchestrator service function + permission gate.
  - ~1h webhook branch (sequential-auto next-fire logic) + idempotency guard so two concurrent webhook deliveries don't double-fire.
  - ~1h completion-email broadcast wiring (reuse `composeCompletionEmail`).
  - ~1.5h ActiveEoiCard UI (button + confirmation modal + automating banner + pause CTA).
  - ~1h vitest covering: automation enable → first invite fires; webhook signs → next invite fires; completion → broadcast email; pause mid-flow → no further auto-fires.
  - ~30 min audit-log entries on enable / pause / auto-fire / broadcast.
- **Alternatives considered + rejected:**
  - **Auto-fire ALL invites at once instead of sequentially** — rejected because Documenso's SEQUENTIAL signing order specifically means signers must wait their turn. Firing all invites at once + asking signers to wait is confusing UX.
  - **Defer to Documenso's native auto-send** — rejected because Documenso's auto-send doesn't trigger our branded invite email path or our post-completion broadcast; the rep gets Documenso's stock emails instead of the per-port-branded templates we ship.
- **Cross-refs:**
  - `documenso_signing_order` per-port setting (already exists per CLAUDE.md Documenso section).
  - `compose-completion-email` route (document-detail.tsx:217 — partially built; this finding finishes the auto-broadcast half).
  - Pairs with the "Documenso upload comprehensive audit" finding above — both touch the upload-for-signing service. Bundle them as one focused Documenso polish wave.
- **Open questions for the user:**
  1. **When the rep enables automation mid-flow (e.g. signer #1 was already manually invited), should the system pick up where they left off, or refuse and require the rep to start from a draft?** Default proposal: pick up — find the next-in-order signer with `invitedAt=NULL` and fire from there. Cleanest UX, matches what reps would expect.
  2. **Completion broadcast scope — does it include CCs and Approvers, or only the SIGNERs?** Default proposal: everyone (the CC role exists specifically to get a copy at the end). If you want a different default, name it.
  3. **Should the rep be able to PARTIALLY automate — fire invites automatically but stop short of the broadcast email?** I'd say no for v1 (one workflow, one mode), but if your reps already split those steps mentally we could offer two distinct modes.
  4. **Existing per-row "Send invitation" + "Send reminder" buttons during automation — keep them visible (as override) or hide entirely?** Default proposal: keep them visible but show "Auto-firing soon" tooltip when the doc is in `sequential_auto`. Reps retain manual control.

### `/documents/new` CreateDocumentWizard — confusing, redundant pathways

- **`MOSTLY SHIPPED locally (not yet committed) — remaining: convert page to dialog`**
- **What shipped (per commit `2107480` `feat(wizard-refactor): drop inapp pathway + upload branch + per-port template defaults + mark-signed dropdown`):**
  - Wizard upload branch removed; `source: 'template'` hard-coded.
  - `pathway: 'documenso-template'` hard-coded; `inapp` removed.
  - Doc-type-driven template defaults: `/api/v1/documents/template-defaults` returns the per-port `documenso_eoi_template_id` / `documenso_reservation_template_id` / `documenso_contract_template_id`; wizard auto-fills the picker when the rep selects a doc type.
  - "Mark as signed (offline)" dropdown item exists in NewDocumentMenu (line 113 of new-document-menu.tsx).
- **Remaining:** drop the `/documents/new` route in favour of a `<GenerateDocumentDialog>` modal opened from the dropdown — architectural change, deferred until the rest of the launch stabilises.
- **React-grab anchor:** `<section class="rounded-md bord..." />` in CreateDocumentWizard in NewDocumentPage.

**Current state — three flows wired three different ways:**

| #   | What                                                                         | Entry point today                                                                                                           | Underlying mechanism                                                                                                                                                                                    |
| --- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | Generate an EOI/Contract/Reservation from a template, send through Documenso | `EoiGenerateDialog` (interest tab) OR `/documents/new` wizard → `Generate from a template` + pathway = `documenso-template` | Template synced to Documenso; CRM calls the Documenso template-generate endpoint with merge-field values; Documenso renders + distributes for signing.                                                  |
| 2   | Upload an arbitrary PDF, place fields manually, send through Documenso       | `UploadForSigningDialog` (interest tabs + NewDocumentMenu dropdown)                                                         | PDF uploaded to storage; rep drags signature/text/date/checkbox fields onto the PDF preview; CRM POSTs PDF + field metadata to Documenso (`field/create-many` on v2 or the legacy `placeFields` on v1). |
| 3   | Upload a PDF that's already signed offline, mark it as signed                | `ExternalEoiUploadDialog` (interest EOI tab) for EOIs; equivalent for Reservation/Contract on their tabs                    | No Documenso involvement; service flips `eoiStatus`/`reservationDocStatus`/`contractDocStatus` to `signed` + advances stage; pure metadata operation.                                                   |

**What's wrong:**

1. **Wizard duplicates the dropdown.** `NewDocumentMenu` already exposes three named actions (Upload file / Upload & send for signature / Generate for signing) that map cleanly to flows 2/2/1. The wizard then takes the rep to `/documents/new`, where they pick AGAIN between "Generate from a template" and "Upload a finished PDF" — the upload branch is just flow 2 reimplemented worse (no field placement UI, just a stored file id).
2. **The "inapp" template pathway is undocumented and probably unused.** The wizard's pathway dropdown offers `documenso-template` (rendered by Documenso) vs `inapp` (rendered by CRM via pdf-lib AcroForm fill, then sent to Documenso for signature). The inapp pathway exists in code but no UI feature surfaces it as a deliberate choice — it's a configuration trap.
3. **Flow 3 (upload externally-signed) has no entry from the wizard or the dropdown.** It's only reachable from the per-interest tabs, which is fine for EOI / Reservation / Contract, but means a rep who lands on `/documents/new` can't even ask for it.
4. **Templates feel like a heavyweight concept.** Reps want to "send an EOI to this client" — they shouldn't have to think about which template id maps to that.

**Why templates exist (do we need them?):**

Templates ARE needed for flow 1 — the generate-via-Documenso path. Documenso requires a pre-built template (with signature/text field placeholders) that lives on its side; the CRM provides merge-field values and Documenso renders the final PDF. We can't ship flow 1 without templates because Documenso's API requires a template id. They ARE NOT needed for flows 2 and 3.

The catch: most ports will have ~3 templates total (EOI, Reservation Agreement, Contract). Hiding the template picker behind a doc-type selector ("EOI" → uses the port's `documenso_eoi_template_id` setting) makes templates invisible to reps — they pick a doc type, the right template loads. Already half-implemented for EOI via `documenso_eoi_template_id`; needs the same treatment for Reservation + Contract.

**Proposed redesign:**

- **Delete the wizard's upload branch.** Flow 2 lives in `UploadForSigningDialog` which is already the right surface. The wizard becomes generation-only.
- **Delete the pathway dropdown.** `inapp` is dead; either remove it or surface it as an admin-only override. Default to `documenso-template`.
- **Replace the template picker with a doc-type-driven default.** Rep picks "EOI / Reservation Agreement / Contract" → wizard resolves the template id from per-port settings (`documenso_eoi_template_id`, `documenso_reservation_template_id`, `documenso_contract_template_id`). For ports that want a non-default template, an admin-only "Use a specific template" override stays.
- **Surface flow 3 from the dropdown menu.** Add "Mark as signed (uploaded offline)" as a fourth dropdown item that opens the appropriate external-signed dialog based on the current entity context.
- **Drop `/documents/new` as a route entirely.** Replace with a `<GenerateDocumentDialog>` opened from the dropdown menu, matching the modal pattern the other flows already use. Saves a page navigation + keeps the entry pattern consistent.

**Effort:** ~6–8h end-to-end. Largest piece is the template-id resolution — needs the per-port settings keys for Reservation + Contract (if not already there) + wizard service migration. UI surgery is ~2h.

**Open questions for the user:**

- Confirm flow 3 (mark externally signed) should be reachable from the dropdown menu, not just from per-interest tabs.
- Confirm the `inapp` pathway can be removed (or do reps still need a CRM-rendered PDF for any edge case the audit hasn't surfaced?).
- Confirm the per-port template-id pattern is the right way to hide templates from reps. Alternative: a one-time admin step to pick the default per doc type, with a "switch template" link visible to admins only.

### CreateDocumentWizard — Reminders/Watchers/Signers leak into upload-only flow

- **`SUPERSEDED`** — _src/components/documents/create-document-wizard.tsx_ (wizard is generation-only since 2026-05-26 refactor; `source: 'template'` hard-coded, upload branch removed).
- **Reason:** the 2026-05-26 wizard refactor cut the upload branch entirely. The wizard is now purely "generate from template → Documenso" so Signers / Reminders / Watchers always apply. Offline-signed upload flows live elsewhere (per-interest external-upload dialogs, generic FileUploadZone). No longer a leak to fix.

### CreateDocumentWizard subject picker — needs at-a-glance entity scan

- **`PARTIALLY SHIPPED locally (not yet committed)`** — _src/components/documents/create-document-wizard.tsx_ (subject row).
- **Fix applied:** type dropdown → segmented button strip (Interest / Tenancy / Client / Company / Yacht), all 5 types visible at once so the rep clicks once instead of opening a dropdown. Picker below still adapts per-type (existing pickers reused as-is).
- **Deferred:** the fully-unified search ("type 'matt'" → mixed-type results) needs a new `<SubjectCombobox>` against `/api/v1/search`. The segmented strip is the high-value 80% fix; the unified search lands when the wider wizard refactor goes through.
- **React-grab anchor:** `<div class="grid grid-cols-..." />` in CreateDocumentWizard.
- **Symptom:** picking the document subject means choosing a type (Client / Company / Yacht / Interest / Tenancy) THEN searching that one type's picker. Reps don't think in terms of "what type is the recipient" — they think "I need to send this to deal X" or "this is for client Y." The two-step type-then-picker requires the rep to know the answer to the type question before they can search.
- **Fix proposal:** replace the type+picker pair with a single unified search field (same idiom as the global Command-search). Typing surfaces matching clients/companies/yachts/interests/tenancies inline, each row carrying its type label as a badge. Recent interactions surface first when the input is empty. The chosen entity sets both `subjectType` and `subjectId` in one click.
- **Bundle with:** the larger wizard refactor (above) — if `/documents/new` becomes a `<GenerateDocumentDialog>`, this is the natural place to ship the unified subject picker as one consistent pattern.

### Admin toggle to disable Residential entirely (module gate)

- **`SHIPPED locally (not yet committed) — 2026-05-31`** — net-new wiring; mirrors the Tenancies / Invoices / Expenses module-toggle pattern.
- **Fix applied (2026-05-31):** full module gate shipped end-to-end, defaulting ON.
  - New `src/lib/services/residential-module.service.ts` (`isResidentialModuleEnabled` / `enableResidentialModule` / `disableResidentialModule` / `assertResidentialModuleEnabled`) — TDD'd via `tests/integration/residential-module.test.ts` (6 tests, RED→GREEN).
  - Registry key `residential_module_enabled` (`section: 'operations.residential'`, `defaultValue: true`) in `src/lib/settings/registry.ts`.
  - Route guard `src/app/(dashboard)/[portSlug]/residential/layout.tsx` renders `<ModuleDisabledPage>` when off — covers all 5 residential pages.
  - Sidebar: `requiresResidentialModule` section flag + `residentialModuleByPort` map resolved SSR in `src/app/(dashboard)/layout.tsx`, threaded through `app-shell.tsx` → `sidebar.tsx`; mobile `more-sheet.tsx` Residential tile gated via new `residentialModuleEnabled` prop.
  - Global search: module gate added at the shared chokepoint (`searchResidentialClients` / `searchResidentialInterests` early-return `[]` when off) so disabled-port records don't dead-end on the guard page — covers both the all-buckets fan-out and the single-bucket `type=` path.
  - Public intake: `src/app/api/public/residential-inquiries/route.ts` now `assertResidentialModuleEnabled` after port resolution → 404 when off (regression test added to `tests/integration/public-residential-inquiry.test.ts`).
  - Admin Switch: `residential_module_enabled` added to `settings-manager.tsx` KNOWN_SETTINGS (writes via `PUT /api/v1/admin/settings/[key]`).
  - **Verification:** tsc clean; lint clean (0 errors); residential-module + public-residential-inquiry + search unit suites green (10 + 22 tests).
  - **Deliberately NOT gated:** the `admin/residential-stages` page stays reachable when the module is off — an admin may legitimately configure residential stages before enabling. Reconsider if the user wants it hidden too.
  - **Deferred (separate cleanup):** the consolidated `admin/operations` page hosting all four module toggles (+ retiring the orphaned `tenancies-module/*` endpoints) — see open question 3 below.
- **User ask (verbatim, 2026-05-31):** "is it possible to make the residential interests sections/functions in the platform to be toggleable in the admin space?"
- **Answer:** yes. The platform already has the exact pattern for Tenancies / Invoices / Expenses; residential can copy it. Caveat: residential is currently gated by **permissions** (`residential_clients` / `residential_interests` access verbs + the `residentialAccess` role flag at _src/lib/db/schema/users.ts:455_, auto-granting perms at _src/lib/api/helpers.ts:209-213_), **not** a module toggle, and has **no layout gate at all** today. So this is genuinely new wiring, not a flag flip.
- **Fix proposal (copy the Tenancies template — the most complete of the three):**
  1. **Registry entry** — add `residential_module_enabled` to _src/lib/settings/registry.ts_ (mirror the `tenancies_module_enabled` entry at lines 614-623): `section: 'operations.residential'`, `type: 'boolean'`, `scope: 'port'`, `defaultValue: true` (residential is in active use; default ON so existing ports aren't surprised — unlike tenancies/invoices which default OFF).
  2. **Module service** — new _src/lib/services/residential-module.service.ts_ mirroring _tenancies-module.service.ts_: `isResidentialModuleEnabled(portId)` / `enableResidentialModule` / `disableResidentialModule` / `assertResidentialModuleEnabled` (throws `NotFoundError` when off; used by API handlers). Lazy "any residential_clients row exists" auto-enable is optional.
  3. **Route gate** — new _src/app/(dashboard)/[portSlug]/residential/layout.tsx_ rendering `<ModuleDisabledPage moduleName="Residential" …>` (copy _expenses/layout.tsx:26-43_). One layout covers all 5 residential pages (clients list/detail, interests list/detail, index redirect). The `admin/residential-stages` page should also be gated.
  4. **Sidebar** — add a `requiresResidentialModule` flag to the Residential nav section in _src/components/layout/sidebar.tsx:119-134_ (alongside the existing `residentialRequired`); resolve a `residentialModuleByPort` map in _src/app/(dashboard)/layout.tsx:82-109_ (mirror the tenancies/expenses maps) and thread it through _src/components/layout/app-shell.tsx:28-34,97-98,150-151_; add the filter at the existing nav filter (sidebar.tsx ~390/419). **Also gate the mobile entry** _src/components/layout/mobile/more-sheet.tsx:58_ (currently ungated).
  5. **Search** — gate the two residential buckets in _src/lib/services/search.service.ts_ (`searchResidentialClients` line 497, `searchResidentialInterests` line 725; permission checks at 1949-1956 / 2163-2169 / 2199-2205) behind the module flag too, plus recently-viewed hydration in _src/lib/services/dashboard.service.ts:484-506_.
  6. **Public inquiry endpoint** — _src/app/api/public/residential-inquiries/route.ts_ should `assertResidentialModuleEnabled` (or 404) when off, so a disabled port stops accepting residential inquiries from the website. Currently only rate-limit + validation gate it.
  7. **Admin UI** — realistic path is the generic settings manager: add a `residential_module_enabled` Switch entry to _src/components/admin/settings/settings-manager.tsx_ (mirror the `tenancies_module_enabled` entry at lines 51-57), writing via `PUT /api/v1/admin/settings/[key]`. **Note:** the dedicated `/api/v1/admin/tenancies-module/enable|disable` endpoints are orphaned (nothing in the UI calls them) and the Invoices toggle has a registry entry + gate but no UI — so the settings-manager Switch is the path that actually works. Optionally build the long-promised `admin/operations` page to host all four module toggles in one place (closes the orphaned-endpoint gap for tenancies too).
- **Surfaces to gate (user-facing, ~a dozen):** 5 dashboard pages (1 new layout), 1 admin stages page, sidebar section, mobile more-sheet entry, 2 search buckets + recently-viewed, public inquiry endpoint. **Backend stays preserved (~28 files):** 4 DB tables + relations (_src/lib/db/schema/residential.ts_), ~12 service fns (_residential.service.ts_, _residential-stages.service.ts_), ~14 v1 API routes (_src/app/api/v1/residential/\*_), 11 components (_src/components/residential/\*_), 2 email templates (_residential-inquiry.tsx_), validators, seeds, constants — disabled but invisible, exactly like the Tenancies/Expenses "soft hide, data preserved" model.
- **Effort:** ~4-6h (half a day). Bulk is the sidebar/app-shell map plumbing + the new layout + search gating; the registry/service/Switch are ~1h.
- **Alternatives considered + rejected:**
  - Reuse the existing permission gate (just strip `residentialAccess` from all roles) — rejected: that's per-user, not a clean port-level "this port doesn't do residential" switch, and leaves the public inquiry endpoint live + the nav logic fragile.
  - Hard-delete residential tables for ports that don't use it — rejected: violates the established non-destructive module-toggle convention (data preserved, re-enable any time).
- **Open questions for the user:**
  1. **Default state** — ON for existing ports (residential is live; least surprising) or OFF (treat residential as opt-in like tenancies/invoices)? Default proposal: ON.
  2. **Scope** — just hide the UI surfaces, or also hard-reject the public residential-inquiry endpoint when off? Default proposal: both (a disabled port shouldn't silently accept inquiries it can't see).
  3. Build the proper `admin/operations` page to host all four module toggles (and retire the orphaned tenancies endpoints), or just add the residential Switch to the existing settings manager? Default proposal: settings-manager Switch now; Operations page as a separate cleanup.
- **Cross-refs:** sibling of the "Admin toggle to disable Tenancies entirely" finding (Bucket 1, `PARTIALLY SHIPPED`) and the invoices module-toggle work in `docs/launch-readiness.md` Initiative 1c. All four toggles share the same incomplete admin-UI story — worth adding the Operations page once and wiring all of them through it.

---

## Bucket 4 — Bugs (severity-tagged)

_None yet._

---

## Append protocol

- **One finding per entry.** Don't bundle multiple distinct issues inside one bullet.
- **Always tag status** as the first inline tag: `OPEN | IN PROGRESS | SHIPPED in <hash> | SHIPPED locally (not yet committed) | PARTIALLY SHIPPED | QUEUED | BLOCKED`.
- **Be incredibly detailed.** Every finding should carry:
  - **File:line evidence** across every layer touched (component + service + validator + migration when relevant — not just the visible component).
  - **React-grab anchor verbatim** when the user pasted one (the `<tag class="..." />` in `Component` chain).
  - **Symptom** describing what the user saw + what they expected. Reference the screenshot's content when one was provided.
  - **Root cause** — explain the actual mechanism (which query, which prop, which filter is wrong). When unknown, list ranked hypotheses.
  - **Fix proposal** concrete enough that a future agent can implement without re-investigating. Name the functions, props, validators, migrations, query keys. Walk each layer in order when the fix touches multiple (service → API → UI).
  - **Effort estimate** (hour range).
  - **Alternatives considered + rejected** when there was a design call to make.
  - **Open questions** for the user when a decision is pending — number them so the user can answer by reference.
  - **Bundle-with** notes when the finding should ship together with another so related fixes don't drift.
  - **Cross-refs** to related findings (by heading) and to shipped commits (by hash).
  - **Acceptance criteria** when the fix is non-trivial — what does "done" look like?
- **Always include file:line evidence** when known — even a guess is better than none.
- **Bucket by effort, not domain.** Quick / Medium / Large / Bug. Cross-domain refactors that touch several files but each touch is small belong in Quick or Medium.
- **Premature or aspirational items still queue.** Reason: the project's feedback memory explicitly says don't silently filter; the finding belongs even if we won't act on it this session.
- **Shipped entries keep their detail.** When marking a finding SHIPPED, edit the status tag and append a "Fix applied:" paragraph below the original symptom + root cause. Don't strip the context — the queue is also the history.
