# Audit Cleanup Completion — Master Plan (2026-05-18, Part 2)

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Close out everything outstanding in `docs/BACKLOG.md` sections A, C, D, E, G, H over a sequenced ~4-week sweep, ending with the backlog drained down to "no concrete trigger" parked items only.

**Architecture:** Six sequenced phases. Phase A is stop-ship priority (security + data integrity). Phase B layers in HIGH security + observability. Phase C re-opens the back-burnered Documenso build (phases 2-7 + Section C deferred items bundled). Phase D is mechanical React Compiler cleanup. Phase E does Wave 6 + 7 validation work on top of the Documenso changes. Phase F mops up Section G dependency migrations + Section D placeholders + Section E hidden tabs. Each phase ships a working, testable cut; commits are atomic per item with a phase-tagged prefix.

**Tech Stack:** Same as the parent CRM — Next.js 16 App Router, React 19, TypeScript strict, Drizzle ORM on PostgreSQL, BullMQ + Redis, better-auth, React-Email + nodemailer + imapflow, Documenso v1/v2 client, pdf-lib for AcroForm fill.

**Estimate:** ~4 weeks focused work end-to-end. Phase A is the only phase where "ship before deploy" is hard-blocking — everything after is post-launch hygiene.

---

## Pre-flight (do once)

- [ ] **P.1 — Verify session-end state**

```bash
git log --oneline -5
pnpm exec vitest run
pnpm exec tsc --noEmit
pnpm lint
```

Expected: clean tsc, 0 lint errors, 1374/1374 vitest pass (or current baseline). Capture baseline number for "no regressions" gate at end of each phase.

- [ ] **P.2 — Verify Wave 2 #12 (admin subject editor) actually outstanding**

```bash
grep -n "overrides?.subject" src/lib/email/templates/*.tsx | wc -l
```

Expected: count ≥ 8 (all templates honour `overrides.subject`). If yes, Wave 2 #12 is **closed in code** by this session's Phase 5 — only the admin UI needs verification. Confirm admin page `/admin/email-templates` exposes a subject input bound to that field; if so, mark #12 ✅ before Phase B starts.

- [ ] **P.3 — Refresh backlog tracker**

Open `docs/BACKLOG.md` and append a note: "2026-05-18 Part 2 plan live at `docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md`."

---

## Phase A — Wave 1 stop-ship CRITICALs (~3-5 days)

Nine items, half-day each, ship in priority order. **Each item is one commit** with prefix `fix(audit-wave-1): <item>`.

### Task A.1 — Real `db:migrate` runner

**Why critical:** `0052_audit_critical_fixes.sql` uses `CREATE INDEX CONCURRENTLY` which silently never runs under `db:push`. Six composite indexes missing in prod. Without a real runner this only ever gets worse.

**Files:**

- Create: `scripts/db-migrate.ts`
- Modify: `package.json` (replace `db:migrate` script)
- Test: `tests/integration/scripts/db-migrate.spec.ts`

- [ ] **Step 1: Write failing test**

```ts
// tests/integration/scripts/db-migrate.spec.ts
import { applyPendingMigrations } from '@/scripts/db-migrate-lib';
import { db } from '@/lib/db';
import { sql } from 'drizzle-orm';
import { describe, expect, it, beforeEach } from 'vitest';

describe('db-migrate runner', () => {
  beforeEach(async () => {
    await db.execute(sql`DROP TABLE IF EXISTS __drizzle_migrations`);
  });

  it('applies pending migrations idempotently', async () => {
    const result1 = await applyPendingMigrations({ dir: 'src/lib/db/migrations' });
    expect(result1.applied.length).toBeGreaterThan(0);
    const result2 = await applyPendingMigrations({ dir: 'src/lib/db/migrations' });
    expect(result2.applied.length).toBe(0);
  });

  it('honours --> statement-breakpoint split and runs CREATE INDEX CONCURRENTLY outside tx', async () => {
    // Verify the runner does NOT wrap CONCURRENTLY statements in a transaction
    // (Postgres rejects CONCURRENTLY inside BEGIN/COMMIT).
    const result = await applyPendingMigrations({ dir: 'src/lib/db/migrations' });
    expect(result.errors).toEqual([]);
  });
});
```

- [ ] **Step 2: Build the runner**

Extract logic into `scripts/db-migrate-lib.ts` (testable) and a thin CLI `scripts/db-migrate.ts` that calls it. Runner contract:

1. Read `src/lib/db/migrations/*.sql` in lexical order.
2. Track applied state in `__drizzle_migrations` table (`id text primary key, applied_at timestamptz`).
3. Split each file on `--> statement-breakpoint`.
4. For each statement: if it contains `CONCURRENTLY` → execute outside any tx; otherwise wrap the file's statements in a single tx.
5. On failure: log statement + abort; do not mark migration applied.

- [ ] **Step 3: Rerun vitest, then wire script**

```bash
pnpm exec vitest run tests/integration/scripts/db-migrate.spec.ts
```

Then in `package.json`: `"db:migrate": "tsx scripts/db-migrate.ts"`.

- [ ] **Step 4: Backfill missing indexes in dev DB**

```bash
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm -c "DROP TABLE IF EXISTS __drizzle_migrations;"
pnpm db:migrate
```

Then verify the 6 indexes from `0052_audit_critical_fixes.sql` exist:

```sql
SELECT indexname FROM pg_indexes WHERE indexname LIKE '%audit_critical%';
```

- [ ] **Step 5: Commit**

```bash
git add scripts/db-migrate.ts scripts/db-migrate-lib.ts tests/integration/scripts/db-migrate.spec.ts package.json
git commit -m "fix(audit-wave-1): real db:migrate runner with CONCURRENTLY support"
```

---

### Task A.2 — `EMAIL_REDIRECT_TO` production guard

**Why critical:** Currently a dev safety net; if accidentally left set in prod, every customer email goes to one dev mailbox.

**Files:**

- Modify: `src/lib/env.ts`
- Modify: `src/lib/email/index.ts`
- Test: `tests/unit/lib/env.spec.ts`

- [ ] **Step 1: Failing test**

```ts
// tests/unit/lib/env.spec.ts (add to existing)
it('rejects EMAIL_REDIRECT_TO when NODE_ENV=production', () => {
  expect(() =>
    serverEnvSchema.parse({ ...validBase, NODE_ENV: 'production', EMAIL_REDIRECT_TO: 'x@y.com' }),
  ).toThrow(/EMAIL_REDIRECT_TO must not be set in production/);
});

it('accepts EMAIL_REDIRECT_TO when NODE_ENV=development', () => {
  expect(() =>
    serverEnvSchema.parse({ ...validBase, NODE_ENV: 'development', EMAIL_REDIRECT_TO: 'x@y.com' }),
  ).not.toThrow();
});
```

- [ ] **Step 2: Update env schema**

In `src/lib/env.ts`, add a `superRefine` after the base parse:

```ts
.superRefine((env, ctx) => {
  if (env.NODE_ENV === 'production' && env.EMAIL_REDIRECT_TO) {
    ctx.addIssue({
      code: 'custom',
      path: ['EMAIL_REDIRECT_TO'],
      message: 'EMAIL_REDIRECT_TO must not be set in production',
    });
  }
});
```

- [ ] **Step 3: Boot warning in mailer**

In `src/lib/email/index.ts` at module top:

```ts
if (env.EMAIL_REDIRECT_TO) {
  logger.warn(
    { emailRedirectTo: env.EMAIL_REDIRECT_TO },
    'EMAIL_REDIRECT_TO is set — every outbound email will be rerouted',
  );
}
```

- [ ] **Step 4: Tests + commit**

```bash
pnpm exec vitest run tests/unit/lib/env.spec.ts
git add src/lib/env.ts src/lib/email/index.ts tests/unit/lib/env.spec.ts
git commit -m "fix(audit-wave-1): reject EMAIL_REDIRECT_TO in production"
```

---

### Task A.3 — Orphan-blob fix in `handleDocumentCompleted`

**Why critical:** Current catch-block leaves blob in storage AND marks `status='completed'` with no `signedFileId`. Storage cost leak + document marked done with no signed PDF retrievable.

**Files:**

- Modify: `src/lib/services/documents.service.ts:1100-1253`
- Test: `tests/integration/services/documents-completion.spec.ts`

- [ ] **Step 1: Failing test**

```ts
// tests/integration/services/documents-completion.spec.ts
it('rolls back storage + DB when files.insert fails mid-completion', async () => {
  // Arrange: stub storage.put to succeed, files.insert to throw
  const putSpy = vi.spyOn(storage, 'put').mockResolvedValueOnce({ key: 'k', sha256: 'x', size: 1 });
  vi.spyOn(db.insert(filesTable), 'values').mockRejectedValueOnce(new Error('boom'));

  await expect(handleDocumentCompleted({ documentId: doc.id, pdfBytes })).rejects.toThrow();

  // Assert: blob deleted from storage
  expect(await storage.head('k').catch(() => null)).toBeNull();
  // Assert: documents.status NOT updated to completed
  const after = await db.query.documents.findFirst({ where: eq(documents.id, doc.id) });
  expect(after?.status).not.toBe('completed');
  expect(after?.signedFileId).toBeNull();
});
```

- [ ] **Step 2: Rewrite as saga**

Inside `handleDocumentCompleted`:

1. `storage.put(...)` → capture key.
2. `withTransaction(async (tx) => { insert files, update documents })`.
3. On tx failure: `await storage.delete(key)` in `finally`/`catch`, then rethrow.

Idempotency early-return (`status='completed' && signedFileId`) stays at the top of the function — already handles 5xx retries.

- [ ] **Step 3: Verify + commit**

```bash
pnpm exec vitest run tests/integration/services/documents-completion.spec.ts
git add src/lib/services/documents.service.ts tests/integration/services/documents-completion.spec.ts
git commit -m "fix(audit-wave-1): handleDocumentCompleted saga compensating delete"
```

---

### Task A.4 — Escape URLs in email templates

**Why critical:** Every template inlines `${data.link}` into `href="…"` without escaping. URL injection risk (template merge tokens could carry a crafted URL).

**Note:** `safeUrl()` already exists in `src/lib/email/shell.ts` and is used by most templates. This task sweeps the few that don't use it.

**Files (audit each):**

- `src/lib/email/templates/portal-auth.tsx`
- `src/lib/email/templates/inquiry-client-confirmation.tsx`
- `src/lib/email/templates/inquiry-sales-notification.tsx`
- `src/lib/email/templates/notification-digest.tsx`
- `src/lib/email/templates/document-signing.tsx`
- `src/lib/email/templates/admin-email-change.tsx`
- `src/lib/email/templates/crm-invite.tsx`
- `src/lib/email/templates/residential-inquiry.tsx`

- [ ] **Step 1: Grep for unsafe usages**

```bash
grep -rn 'href={data\.\|href={`' src/lib/email/templates/
```

For each match: wrap with `safeUrl(...)`. Schema: `safeUrl` already allows only http/https/mailto.

- [ ] **Step 2: Add safeUrl coverage test**

```ts
// tests/unit/lib/email/shell.spec.ts (add)
it.each([
  ['javascript:alert(1)', '#'],
  ['data:text/html,xss', '#'],
  ['ftp://x', '#'],
  ['https://ok.com/path', 'https://ok.com/path'],
  ['mailto:a@b.c', 'mailto:a@b.c'],
])('safeUrl(%s) → %s', (input, expected) => {
  expect(safeUrl(input)).toBe(expected);
});
```

- [ ] **Step 3: Commit**

```bash
git add src/lib/email/templates/ tests/unit/lib/email/shell.spec.ts
git commit -m "fix(audit-wave-1): route all email template URLs through safeUrl"
```

---

### Task A.5 — Replace 16 native `window.confirm()` calls

**Why critical:** Destructive flows bypass `ConfirmationDialog`/`AlertDialog`. ui-ux-auditor's C1 itemized them. Inconsistent UX + native confirm can't be styled or scripted.

**Files (16 sites — grep to enumerate):**

```bash
grep -rn 'window.confirm\|confirm(' src/components/ src/app/ --include='*.tsx' | grep -v node_modules | grep -v 'window\.confirmDocumentMooring'
```

Expected sites include: cancel signing, delete file, archive interest/company/yacht, delete folder, etc.

- [ ] **Step 1: Identify shared dialog primitive**

`ConfirmationDialog` exists at `src/components/shared/confirmation-dialog.tsx`. Pattern: `useState<boolean>` for open, `<ConfirmationDialog title="…" description="…" confirmLabel="Archive" variant="destructive" onConfirm={…} />`.

- [ ] **Step 2: Sweep — 30 min per site × 16**

For each `window.confirm(...)` call:

1. Add `useState` for dialog open.
2. Replace the `if (window.confirm(...))` guard with `setConfirmOpen(true)`.
3. Move action body into `onConfirm` handler.
4. Render `<ConfirmationDialog open={confirmOpen} onOpenChange={setConfirmOpen} ... />`.

- [ ] **Step 3: Add ESLint rule against `window.confirm`**

In `eslint.config.mjs`:

```js
{
  rules: {
    'no-restricted-globals': ['error', { name: 'confirm', message: 'Use <ConfirmationDialog> instead' }],
  },
}
```

- [ ] **Step 4: Commit**

```bash
git add src/components/ src/app/ eslint.config.mjs
git commit -m "fix(audit-wave-1): replace 16 window.confirm() with ConfirmationDialog"
```

---

### Task A.6 — GDPR Article-15 export completeness

**Why critical:** Regulator-finding-level gap. Current bundle missing: portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions.

**Files:**

- Modify: `src/lib/services/gdpr-bundle-builder.ts`
- Test: `tests/integration/services/gdpr-bundle.spec.ts`

- [ ] **Step 1: Enumerate missing relations**

For a single test client, seed at least one row in each missing table linking back via `clientId` (or polymorphic FK where applicable).

- [ ] **Step 2: Failing test**

```ts
it('includes every PII-bearing relation in the GDPR bundle', async () => {
  const bundle = await buildGdprBundle(testClient.id);
  expect(Object.keys(bundle)).toEqual(
    expect.arrayContaining([
      'client',
      'addresses',
      'contacts',
      'interests',
      'notes',
      'documents',
      'tags',
      // newly required:
      'portalUsers',
      'emailThreads',
      'emailMessages',
      'documentSends',
      'reminders',
      'files',
      'scratchpadNotes',
      'clientMergeLog',
      'contactLog',
      'websiteSubmissions',
      'formSubmissions',
    ]),
  );
});
```

- [ ] **Step 3: Add the 10 missing relation loaders**

Each loader is a Drizzle query bounded by `port_id` + `client_id` (or polymorphic resolver where the relation doesn't carry client_id directly — use the symmetric-reach pattern from `listFilesAggregatedByEntity`).

- [ ] **Step 4: Commit**

```bash
git add src/lib/services/gdpr-bundle-builder.ts tests/integration/services/gdpr-bundle.spec.ts
git commit -m "fix(audit-wave-1): GDPR bundle includes 10 missing relations"
```

---

### Task A.7 — Right-to-be-forgotten true wipe

**Why critical:** `client-hard-delete.service.ts` nullifies FKs but leaves verbatim PII in `email_messages.body_html`, `files`, `document_sends.recipient_email`. Doesn't satisfy Article-17.

**Files:**

- Modify: `src/lib/services/client-hard-delete.service.ts`
- Test: `tests/integration/services/client-hard-delete.spec.ts`

- [ ] **Step 1: Failing test**

```ts
it('overwrites PII in email_messages.body_html and document_sends', async () => {
  await hardDeleteClient({ clientId, requestedBy: actor });
  const msgs = await db.query.emailMessages.findMany({
    where: eq(emailMessages.clientId, clientId),
  });
  msgs.forEach((m) => {
    expect(m.bodyHtml).toBe('');
    expect(m.bodyText).toBe('');
    expect(m.subject).toBe('[erased]');
  });
  const sends = await db.query.documentSends.findMany({
    where: eq(documentSends.clientId, clientId),
  });
  sends.forEach((s) => expect(s.recipientEmail).toBe('[erased]'));
});

it('deletes file blobs from storage', async () => {
  const file = await db.query.files.findFirst({ where: eq(files.clientId, clientId) });
  await hardDeleteClient({ clientId, requestedBy: actor });
  expect(await storage.head(file!.storageKey).catch(() => null)).toBeNull();
});
```

- [ ] **Step 2: Add wipe pass**

Inside the existing tx:

1. `UPDATE email_messages SET body_html = '', body_text = '', subject = '[erased]' WHERE client_id = $1`.
2. `UPDATE document_sends SET recipient_email = '[erased]' WHERE client_id = $1`.
3. For each `files` row: delete blob via `storage.delete(key)`, then delete the row. Errors on storage.delete should not block tx — log and continue (blob orphan is acceptable; row deletion is the legally required action).

- [ ] **Step 3: Commit**

```bash
git add src/lib/services/client-hard-delete.service.ts tests/integration/services/client-hard-delete.spec.ts
git commit -m "fix(audit-wave-1): client hard-delete true-wipe PII text + blobs"
```

---

### Task A.8 — `user_permission_overrides.user_id` FK + `onDelete='set null'`

**Why critical:** Orphan rows accumulate when users are deleted.

**Files:**

- Create: `src/lib/db/migrations/0076_user_permission_overrides_fk.sql`

- [ ] **Step 1: Write migration**

```sql
ALTER TABLE user_permission_overrides
  ADD CONSTRAINT user_permission_overrides_user_id_fkey
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
```

- [ ] **Step 2: Apply + verify**

```bash
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm -f src/lib/db/migrations/0076_user_permission_overrides_fk.sql
```

Then in the Drizzle schema `src/lib/db/schema/permissions.ts` (or wherever `userPermissionOverrides` lives): add `.references(() => users.id, { onDelete: 'set null' })` to the column.

- [ ] **Step 3: Commit**

```bash
git add src/lib/db/migrations/0076_user_permission_overrides_fk.sql src/lib/db/schema/
git commit -m "fix(audit-wave-1): user_permission_overrides.user_id FK + set null"
```

---

### Task A.9 — Resolve-identifier endpoint replacement

**Why critical:** Currently echoes the real canonical email on a successful username hit. Even rate-limited, this is a username-to-email mapping oracle.

**Files:**

- Delete (or refactor): `src/app/api/auth/resolve-identifier/route.ts`
- Modify: `src/app/(auth)/login/page.tsx` (form action)
- Create: `src/app/api/auth/sign-in-proxy/route.ts`
- Test: `tests/integration/api/auth-sign-in-proxy.spec.ts`

- [ ] **Step 1: Failing test**

```ts
it('never returns canonical email on identifier+password attempt', async () => {
  // Success path
  const ok = await POST(req({ identifier: 'username1', password: 'correct-pass' }));
  const okBody = await ok.json();
  expect(okBody).not.toHaveProperty('email');
  expect(okBody.data).toMatchObject({ ok: true });

  // Failure path
  const bad = await POST(req({ identifier: 'username1', password: 'wrong' }));
  const badBody = await bad.json();
  expect(badBody).not.toHaveProperty('email');
});
```

- [ ] **Step 2: Build proxy**

New endpoint accepts `{ identifier, password }`, resolves canonical email server-side, calls `auth.api.signInEmail({ body: { email, password } })`, returns `{ data: { ok: true } }` with session cookie set on success, `{ error: { code: 'INVALID_CREDENTIALS' } }` on failure. Same response for "no such identifier" and "wrong password" (timing-equalize with a dummy bcrypt compare on miss).

- [ ] **Step 3: Update login form**

`src/app/(auth)/login/page.tsx` posts to the proxy directly with the user-entered identifier; no two-step flow.

- [ ] **Step 4: Delete old endpoint**

```bash
git rm src/app/api/auth/resolve-identifier/route.ts
```

- [ ] **Step 5: Commit**

```bash
git add src/app/api/auth/sign-in-proxy/ src/app/\(auth\)/login/page.tsx tests/integration/api/auth-sign-in-proxy.spec.ts
git commit -m "fix(audit-wave-1): sign-in proxy replaces resolve-identifier email leak"
```

---

### Phase A acceptance gate

- [ ] `pnpm exec vitest run` — all green, ≥ baseline count
- [ ] `pnpm exec tsc --noEmit` — clean
- [ ] `pnpm lint` — 0 errors
- [ ] `pnpm exec playwright test --project=smoke` — green
- [ ] All 9 commits land
- [ ] Update `docs/BACKLOG.md` §H Wave 1 — mark all 9 items ✅
- [ ] Hand off Phase B

---

## Phase B — Wave 2 HIGH security + observability (~5-7 days)

Six items, ~half-day each. Prefix: `fix(audit-wave-2): <item>`.

### Task B.1 — `audit_logs.metadata` PII masking + 90-day retention

**Files:**

- Modify: `src/lib/audit.ts` (extend `maskSensitiveFields` to cover `metadata` JSONB)
- Create: `src/jobs/processors/audit-retention.ts` (90-day cron)
- Modify: `src/lib/queue/scheduler.ts` (register cron)
- Test: `tests/unit/lib/audit-mask.spec.ts`

- [ ] **Step 1: Failing test for masking**

```ts
it('masks email/phone/password/token in metadata JSONB', () => {
  const masked = maskSensitiveFields({
    metadata: { email: 'a@b.c', phone: '+44...', password: 'x', other: 'keep' },
  });
  expect(masked.metadata.email).toBe('[redacted]');
  expect(masked.metadata.phone).toBe('[redacted]');
  expect(masked.metadata.password).toBe('[redacted]');
  expect(masked.metadata.other).toBe('keep');
});
```

- [ ] **Step 2: Add retention worker**

Mirror the `error_events` retention worker. Daily cron deleting rows where `created_at < now() - interval '90 days'`. Register in scheduler.

- [ ] **Step 3: Commit**

```bash
git commit -m "fix(audit-wave-2): audit_logs metadata PII mask + 90d retention"
```

### Task B.2 — Webhook → error pipeline

**Files (sweep):**

- `src/app/api/webhooks/documenso/route.ts`
- `src/app/api/webhooks/imap-bounce/route.ts` (if any)
- Any other `/api/webhooks/*` routes

- [ ] **Step 1: Locate webhook routes**

```bash
ls src/app/api/webhooks/
```

- [ ] **Step 2: Wrap each handler**

```ts
try {
  await handle(req);
} catch (err) {
  await captureErrorEvent(err, { source: 'webhook:documenso', requestId });
  throw err;
}
```

- [ ] **Step 3: Test that thrown errors are captured**

For each webhook, integration-test that a forced throw lands in `error_events`.

- [ ] **Step 4: Commit**

```bash
git commit -m "fix(audit-wave-2): webhooks emit errors to captureErrorEvent"
```

### Task B.3 — Admin email-template subject editor wiring verification

**Status:** Phase 5 of the post-audit master plan rewrote all 8 templates to honour `overrides.subject`. This task confirms the admin UI surfaces it and adds a test.

**Files:**

- Verify: `src/app/(dashboard)/[portSlug]/admin/email-templates/page.tsx`
- Verify: `src/lib/services/mailer-config.ts` `getTemplateOverridesForKey()`
- Test: `tests/integration/services/mailer-config.spec.ts`

- [ ] **Step 1: Manually walk admin page** — confirm subject input present + saves.
- [ ] **Step 2: Integration test that override is applied**

```ts
it('honours subject override from email_templates table', async () => {
  await db
    .update(emailTemplates)
    .set({ subject: 'Custom override' })
    .where(eq(emailTemplates.key, 'portal_activation'));
  const result = await portalAuthEmail(testData, {
    subject: await getTemplateOverridesForKey('portal_activation').subject,
  });
  expect(result.subject).toBe('Custom override');
});
```

- [ ] **Step 3: Commit (or close as already-done if test passes against current code)**

```bash
git commit -m "test(audit-wave-2): verify admin subject override flows to render"
```

### Task B.4 — Admin signature/footer fields wire-up OR delete UI

**Files:**

- Modify: `src/lib/email/shell.ts` (read `email_signature_html` + `email_footer_html`)
- Modify: `src/app/(dashboard)/[portSlug]/admin/email/page.tsx` (if deleting)

- [ ] **Step 1: Decide — wire or delete**

If admin already uses signature/footer in practice (check `system_settings` for non-null rows): wire. If unused: delete the UI and the columns.

- [ ] **Step 2 (wire path): Extend shell**

`renderShell({...})` accepts optional `signatureHtml` + `footerHtml` from per-port system_settings. Both run through allowlist sanitizer (`isomorphic-dompurify` already in deps).

- [ ] **Step 3 (wire path): Plumb in `mailer-config.ts`**

Load signature/footer alongside branding in every `send*` site.

- [ ] **Step 4: Commit**

```bash
git commit -m "fix(audit-wave-2): wire admin signature/footer through email shell"
```

### Task B.5 — PII redaction in error pipeline

**Files:**

- Modify: `src/lib/observability/error-events.ts` (or wherever `request_body_excerpt` sanitizer lives)
- Test: `tests/unit/lib/observability/error-sanitizer.spec.ts`

- [ ] **Step 1: Failing test**

```ts
it.each([
  ['email', { email: 'a@b.c' }, /\[redacted\]/],
  ['phone', { phone: '+44 1234 567890' }, /\[redacted\]/],
  ['firstName', { firstName: 'Alice' }, /\[redacted\]/],
  ['dateOfBirth', { dob: '1990-01-01' }, /\[redacted\]/],
  ['address', { addressLine1: '10 Downing St' }, /\[redacted\]/],
])('redacts %s', (_, input, expected) => {
  expect(JSON.stringify(sanitize(input))).toMatch(expected);
});
```

- [ ] **Step 2: Extend sanitizer key list**

Add: `email`, `phone`, `firstName`, `lastName`, `fullName`, `dateOfBirth`, `dob`, `addressLine1`, `addressLine2`, `city`, `postalCode`, `country`.

- [ ] **Step 3: Commit**

```bash
git commit -m "fix(audit-wave-2): error sanitizer redacts PII keys"
```

### Task B.6 — Notification email worker XSS

**Files:**

- Modify: `src/lib/queue/workers/notifications.ts:65-71`
- Test: `tests/unit/queue/notifications-xss.spec.ts`

- [ ] **Step 1: Failing test**

```ts
it('escapes notification description and link in HTML', async () => {
  const html = await buildNotificationHtml({
    description: '<script>alert(1)</script>',
    link: 'javascript:alert(1)',
  });
  expect(html).not.toContain('<script>');
  expect(html).toContain('&lt;script&gt;');
  expect(html).not.toMatch(/href="javascript:/);
});
```

- [ ] **Step 2: Apply `escapeHtml` + `safeUrl`**

Wrap both interpolation sites. `escapeHtml` already exists in `src/lib/email/shell.ts`.

- [ ] **Step 3: Commit**

```bash
git commit -m "fix(audit-wave-2): notification email worker escapes user content"
```

### Phase B acceptance gate

- [ ] All 6 commits land
- [ ] No regressions in vitest / tsc / lint / smoke
- [ ] `docs/BACKLOG.md` §H Wave 2 marked ✅

---

## Phase C — Documenso phases 2-7 + Section C bundled deferred items (~30-40h)

**Sequencing:** Phase 7 first (1h independent pickoff), then Phase 2 (3-4h, foundation for everything else), then Section C Documenso-related items (bundled with Phase 2), then Phase 3 (6-8h custom upload), then Phase 4 (10-14h field placement UI — partially built by Phase 7 template editor this session), then Phase 5 (1-2h verification needing live v2), then Phase 6 polish (each 2-3h, optional).

Prefix: `feat(documenso-p<N>): <scope>`.

### Task C.1 — Documenso Phase 7 (Project Director RBAC)

**Files:**

- Modify: `src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx`
- Modify: `src/lib/services/documenso-client.ts` (developer/approver user mapping)
- Schema fields `documenso_developer_user_id`, `documenso_approver_user_id`, `_label` already exist.

- [ ] **Step 1: Add user-picker dropdown to admin page**

`<Select>` populated from `/api/v1/users` (current port). On select: store user id + label in `system_settings`. Auto-fill `developer_email` + `developer_name` from picked user.

- [ ] **Step 2: Webhook handler matches against linked user's email**

In `src/app/api/webhooks/documenso/route.ts`: when handling signing events, if recipient.email matches linked developer/approver user email, also push status update into that user's in-CRM notification.

- [ ] **Step 3: Commit**

```bash
git commit -m "feat(documenso-p7): Project Director RBAC UI binding"
```

### Task C.2 — Documenso Phase 2 + Section C deferred items (bundled)

**Why bundled:** Section C items 1-2 (webhook port_id enforcement, recipient_email dedup) touch the same handler code as Phase 2.

**Files:**

- Modify: `src/app/api/webhooks/documenso/route.ts` (cascading "your turn" emails, token-based recipient matching, idempotency lock, port_id enforcement)
- Create: `src/lib/db/migrations/0077_document_events_recipient_email.sql` (add `recipient_email` column + composite unique)
- Schema: add `recipient_email` to `documentEvents`

- [ ] **Step 1: Migration**

```sql
ALTER TABLE document_events ADD COLUMN recipient_email text;
DROP INDEX IF EXISTS uniq_document_events_body_hash;
CREATE UNIQUE INDEX uniq_document_events_dedup
  ON document_events (documenso_document_id, recipient_email, event_type)
  WHERE recipient_email IS NOT NULL;
-- Fallback unique on body_hash for events without recipient context
CREATE UNIQUE INDEX uniq_document_events_body_hash_fallback
  ON document_events (body_hash)
  WHERE recipient_email IS NULL;
```

- [ ] **Step 2: Enforce port_id**

Every `db.query.documents.findFirst({ where: ... })` in the webhook handler gains a `port_id` derivation from the document's signer-set + recipient_email match, asserts the match, refuses to act if mismatched.

- [ ] **Step 3: Cascading "your turn" emails**

When `RECIPIENT_SIGNED` lands and there's a next signer in sequence (`document_signers.order` ascending), send a "your turn" notification to that signer. Use `document-signing.tsx` template with `nextSignerToken` deep link.

- [ ] **Step 4: On-completion PDF distribution**

When `DOCUMENT_COMPLETED` lands: pull `documents.completion_cc_emails` (already present from Phase 1) and attach signed PDF to each as a separate send via `sendMail({ to, attachments: [{ filename, content }] })`.

- [ ] **Step 5: Token-based recipient matching**

Use `document_signers.signing_token` as the lookup key when webhook payload includes a token (v2 `recipient.token` or v1 `signer.signingToken`).

- [ ] **Step 6: Idempotency lock**

`pg_advisory_xact_lock(hashtext($1))` keyed on `documenso_document_id` for the duration of the handler.

- [ ] **Step 7: Tests**

```ts
// tests/integration/webhooks/documenso-cascading.spec.ts
it('sends your-turn email to next signer in sequence');
it('attaches signed PDF to completion_cc_emails on COMPLETED');
it('refuses to act on document with mismatched port_id');
it('deduplicates per (documensoDocumentId, recipientEmail, eventType)');
it('serializes concurrent webhook deliveries via advisory lock');
```

- [ ] **Step 8: Commit**

```bash
git commit -m "feat(documenso-p2): webhook cascading + port_id + per-recipient dedup + idempotency"
```

### Task C.3 — Documenso Phase 3 (Custom doc upload-to-Documenso)

**Files:**

- Create: `src/lib/services/custom-document-upload.service.ts`
- Create: `src/app/api/v1/interests/[id]/upload-for-signing/route.ts` + sibling `handlers.ts`
- Create: UI surface — likely a button in `<InterestDocumentsTab>` opening the field-placement UI from Phase 4
- Test: `tests/integration/services/custom-document-upload.spec.ts`

- [ ] **Step 1: Service contract**

```ts
export async function uploadCustomDocumentForSigning(opts: {
  interestId: string;
  uploadedFile: { name: string; bytes: Buffer; mime: string };
  signers: Array<{
    role: 'signer' | 'cc' | 'approver';
    email: string;
    name: string;
    order?: number;
  }>;
  fieldPlacements: Array<{
    recipientEmail: string;
    type: string;
    page: number;
    x: number;
    y: number;
    w: number;
    h: number;
  }>;
  sendImmediately?: boolean;
}): Promise<{ documentId: string; documentRecordId: string }>;
```

- [ ] **Step 2: Implementation** — v1 path uses `/api/v1/documents` + `/api/v1/documents/{id}/fields` + `/distribute`. v2 path uses `/api/v2/envelope` multipart create + `field/create-many` + `distribute`. Routes through existing `documenso-client.ts` wrappers.

- [ ] **Step 3: API route**

`withAuth(withPermission('documents.create_for_signing'))`, parseBody schema validates multipart, returns `{ data: { documentRecordId, documensoDocumentId } }`.

- [ ] **Step 4: Commit**

```bash
git commit -m "feat(documenso-p3): custom doc upload-for-signing endpoint + service"
```

### Task C.4 — Documenso Phase 4 (Field placement UI)

**Note:** Phase 7 template editor (this session) covers click-place / drag / 4-corner resize / multi-page / right-click delete / responsive PDF width / unsaved guard. **Re-use that component.** New piece is the auto-detect anchor scanner via `pdfjs-dist getTextContent`.

**Files:**

- Modify: existing template editor `src/components/admin/templates/template-editor.tsx` → extract a reusable `<FieldPlacementCanvas>` component
- Create: `src/lib/pdf/anchor-scanner.ts` (text-position extractor)
- Create: UI surface where user uploads PDF + previews scanner results

- [ ] **Step 1: Extract reusable canvas component**

The template-editor.tsx state machine for markers + drag + resize lifts into `<FieldPlacementCanvas pdfUrl onMarkersChange initialMarkers signers />`. Template editor becomes a thin wrapper around it; custom-upload UI uses the same component.

- [ ] **Step 2: Anchor scanner**

```ts
export async function scanAnchors(
  pdfBytes: Uint8Array,
  patterns: Array<{ regex: RegExp; fieldHint: string }>,
): Promise<Array<{ page: number; x: number; y: number; matched: string; fieldHint: string }>>;
```

Uses pdfjs-dist `getTextContent` per page. Default patterns from build-plan Phase 4 (signature, date, full name, initials).

- [ ] **Step 3: UI**

"Scan for anchors" button on the field-placement screen → shows ghost markers at detected positions → user accepts/rejects → markers go into placement set.

- [ ] **Step 4: Commit**

```bash
git commit -m "feat(documenso-p4): field placement canvas + anchor scanner"
```

### Task C.5 — Documenso Phase 5 + Section C v2 voidDocument verification (bundled)

**Note:** Needs a live v2 instance. Defer if unavailable.

**Files:**

- Verify: `signerMessages` map in website project
- Verify: `voidDocument` v2 endpoint shape in `documenso-client.ts`
- Modify: nginx CORS block per integration audit

- [ ] **Step 1: With live v2 instance — POST a doc + cancel it**
- [ ] **Step 2: Confirm `documenso-client.ts:voidDocument` payload shape matches v2**
- [ ] **Step 3: Update `signerMessages` map across signer-role × documentType combinations**
- [ ] **Step 4: Apply nginx CORS block**

```bash
git commit -m "feat(documenso-p5): embedded signing URL + v2 void verification"
```

### Task C.6 — Documenso Phase 6 polish (optional, each ~2-3h)

Itemize → pick subset based on appetite. Items: auto-send delay, audit-log additions, per-document customisation, document expiration, reminder rate-limit display, failed-webhook recovery UI. Defer all six if appetite is exhausted.

### Task C.7 — Section C: public POST routes refactor

**Files:**

- Create: `src/lib/services/public-interest.service.ts`
- Modify: `src/app/api/public/{interests,website-inquiries,residential-inquiries}/route.ts`

- [ ] **Step 1: Extract `publicInterestService.create(...)` from each public route**
- [ ] **Step 2: Routes become thin wrappers**
- [ ] **Step 3: Service unit tests cover dedup, port resolution, branding fan-out**
- [ ] **Step 4: Commit**

```bash
git commit -m "refactor(public-routes): extract publicInterestService"
```

### Phase C acceptance gate

- [ ] Phase 7, 2, 3, 4 ship at minimum (Phase 5 + 6 + 7-refactor optional based on live-v2 availability + appetite)
- [ ] Section C Documenso-related items closed via Phase 2
- [ ] Section C public-POST refactor closed via C.7

---

## Phase D — Wave 3 React Compiler `set-state-in-effect` cleanup (~1 day)

40 remaining warnings. Two templates established:

- **List/load pattern** → TanStack Query. Template: `src/components/admin/tags/tag-list.tsx`.
- **Dialog open→reset pattern** → keyed inner DialogBody. Template: `src/components/clients/hard-delete-dialog.tsx`.

Prefix: `fix(audit-wave-3): <component>`.

- [ ] **Step 1: Enumerate**

```bash
pnpm lint 2>&1 | grep -E 'react-hooks/set-state-in-effect' | sed 's/^.*: //' | sort -u
```

Should yield ~40 file:line tuples.

- [ ] **Step 2: Migrate in batches of ~10**

For each file:

- If `useEffect → fetch → setState` → switch to `useQuery({ queryKey, queryFn })`.
- If `useEffect` resetting form on prop change → keyed inner component.

Run lint after each batch; verify count drops.

- [ ] **Step 3: Promote rule to error**

In `eslint.config.mjs`:

```js
'react-hooks/set-state-in-effect': 'error',
```

- [ ] **Step 4: Commit (one per batch)**

```bash
git commit -m "fix(audit-wave-3): migrate batch <N> to TanStack Query"
```

Final commit:

```bash
git commit -m "fix(audit-wave-3): promote set-state-in-effect to error"
```

### Phase D acceptance gate

- [ ] `pnpm lint` — 0 `set-state-in-effect` warnings
- [ ] Rule is `error` in eslint.config.mjs
- [ ] No vitest / smoke regressions

---

## Phase E — Wave 6 Documenso depth + Wave 7 reporting/recommender quality (~1 week)

Validation work on top of Phase C. Two sub-phases.

### Task E.1 — Wave 6 Documenso integration depth audit

**Files:** all of `src/lib/services/documenso-client.ts`, `src/app/api/webhooks/documenso/`

- [ ] **Step 1: Read `docs/documenso-integration-audit.md` end-to-end**
- [ ] **Step 2: Live-v2 instance walkthrough — every webhook event type, every API call wrapped by documenso-client**
- [ ] **Step 3: Verify per-port redirect URL, sequential signing flag, signing-URL emission**
- [ ] **Step 4: File audit results in `docs/documenso-depth-audit-2026-05-19.md` with findings**
- [ ] **Step 5: Address any P0/P1 findings — open as commits**

### Task E.2 — Wave 6 Email deliverability sanity check

- [ ] **Step 1: Verify bounce monitoring against real bounced delivery** (Phase 6 IMAP poller from prior session — needs ops to seed `IMAP_*` env + send a bounce-bait email)
- [ ] **Step 2: Attachment threshold UX** — confirm 24h signed-URL link path renders sensibly when files exceed `email_attach_threshold_mb`

### Task E.3 — Wave 7 reporting math correctness

**Files:**

- `src/lib/services/reports/revenue.ts`
- `src/lib/services/reports/pipeline-funnel.ts`
- `src/lib/services/reports/occupancy.ts`

- [ ] **Step 1: Build a deterministic test fixture** — synthetic port with 50 interests across 7 stages over 12 months
- [ ] **Step 2: Hand-compute expected revenue, funnel rates, occupancy for the fixture**
- [ ] **Step 3: Snapshot test against current code output; reconcile any divergence**

### Task E.4 — Wave 7 berth recommender quality

- [ ] **Step 1: Build tier-ladder edge-case fixture** — interests at every boundary (just under/over each tier threshold)
- [ ] **Step 2: Snapshot expected tier assignments + heat-score order**
- [ ] **Step 3: Verify against current `berth-recommender.service.ts` output**

### Phase E acceptance gate

- [ ] Documenso depth audit doc published
- [ ] Bounce monitoring round-trip green
- [ ] Reporting + recommender snapshot tests in place + green
- [ ] Mark Waves 6 + 7 ✅ in BACKLOG

---

## Phase F — Section G opportunistic + Section D placeholders + Section E hidden tabs (rolling)

Lowest priority. Ship as touched.

### Task F.1 — Section G `.toLocale*` → `formatDate(...)` rolling sweep

93 sites. Migrate when a file is touched for another reason. No standalone PR — flag it in the commit message.

### Task F.2 — Section G drizzle-zod for simple validators

~28 candidates. Migrate when validator file is touched. Same as F.1.

### Task F.3 — Section G `<DataTable virtual />` wiring

Three concrete sites: admin/audit-log-list, super-admin port switcher, client export modal. ~15min each.

```bash
git commit -m "perf(audit-section-g): virtualize admin/audit-log-list"
git commit -m "perf(audit-section-g): virtualize super-admin port switcher"
git commit -m "perf(audit-section-g): virtualize client export modal"
```

### Task F.4 — Section D scheduler.ts:44 per-user reminder schedule

Placeholder — no concrete trigger. Decision: leave as-is, document explicitly in `docs/BACKLOG.md` §D.

### Task F.5 — Section D workers/import.ts:13 CSV/Excel import

Placeholder — nothing enqueues `import` jobs. Decision: leave as-is, same.

### Task F.6 — Section E Interest Contract/Reservation tabs

**Gated on:** Phase C (Documenso 2-6) shipping.

After Phase C lands, replace the "coming soon" cards with real flow:

- Contract tab → custom-doc upload + signing status timeline
- Reservation tab → berth_reservations CRUD + Documenso send-out

```bash
git commit -m "feat(audit-section-e): interest contract + reservation tabs"
```

### Phase F acceptance gate

- [ ] Rolling sweeps documented as "in-progress" in BACKLOG (not "outstanding")
- [ ] Hidden tabs delivered if Phase C complete

---

## Final action (end of plan)

- [ ] Update `docs/BACKLOG.md` — all addressed sections marked ✅, parked items documented
- [ ] Update `docs/MASTER-PLAN-2026-05-18.md` — note completion of Part 2
- [ ] Final session commit: `docs(plan): close audit cleanup Part 2 — sections A, B, C, D, E, F, G, H closed`
- [ ] Restart dev server + open Playwright for manual verification

---

## Risk + scope notes

- **Phase A is the only hard-blocker.** Everything else is post-launch hygiene.
- **Phase C Phase 5 needs a live v2 Documenso instance.** Defer if not available; doesn't block 7/2/3/4.
- **Phase E reporting math** may surface real bugs — budget for follow-up commits.
- **Phase F.4 + F.5** are explicitly DEFERRED; included so a future engineer sees the explicit decision.
- If 4 weeks is too long: ship Phase A alone (3-5 days) for production readiness; everything else becomes future-rolling work.

---

# VERIFICATION SUPPLEMENT (2026-05-18, post-plan)

> **This section supersedes the phase scoping above.** A 5-agent Sonnet 1M verification sweep against current `main` found that ~60% of items in the plan are already shipped. Real outstanding scope shrinks from ~4 weeks to ~1.5 weeks of focused work. Read this section first; treat phases A-F above as historical context.
>
> Verification methodology: parallel Explore agents with `model: "claude-sonnet-4-6[1m]"` explicit override, narrow scope (4-5 items each), full file:line evidence trail. All findings cross-cite source files at specific lines.

## Verified status — 38 items checked

### ✅ Already DONE (23 items — no work needed)

| Item                                                   | Evidence summary                                                                                                                                                                                                                   |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A.1 — db:migrate runner**                            | `scripts/db-migrate.ts` (3 modes: apply/status/baseline, CONCURRENTLY-aware, journal-ordered, hash-tracked in `__drizzle_migrations`)                                                                                              |
| **A.2 — EMAIL_REDIRECT_TO prod guard**                 | `src/lib/env.ts:115-130` superRefine                                                                                                                                                                                               |
| **A.3 — Orphan-blob tx in `handleDocumentCompleted`**  | `documents.service.ts:1369` idempotency + `:1449` `db.transaction()` + `:1456` SELECT FOR UPDATE re-check + `:1546-1558` compensating `storage.delete(putStoragePath)`                                                             |
| **A.4 — URL escape in templates**                      | `safeUrl()` consistent across all 7 templates; 0 raw hrefs                                                                                                                                                                         |
| **A.5 — `window.confirm()` replacement**               | 0 call sites; `use-confirmation.tsx:32` JSDoc references 17 migrated flows                                                                                                                                                         |
| **A.6 — GDPR Article-15 completeness**                 | `gdpr-bundle-builder.ts:25-244` — all 10 required relations loaded (portalUsers, emailThreads/Messages, documentSends, reminders, files, scratchpadNotes, clientMergeLog, interestContactLog, websiteSubmissions, formSubmissions) |
| **A.9 — Resolve-identifier replacement**               | `sign-in-by-identifier/route.ts` — `.invalid` TLD timing-safe miss response, IP rate-limit with Retry-After, server-side resolution never leaks canonical email                                                                    |
| **W2.10 — `audit_logs.metadata` mask + 90d retention** | `audit.ts:274` mask at insert; `maintenance.ts:151-162` retention; `scheduler.ts:72` `'15 6 * * *'` daily cron _(minor camelCase fragment gap — see W2.14 below)_                                                                  |
| **W2.11 — Webhook → error pipeline**                   | `webhooks/documenso/route.ts:313-323` `captureErrorEvent` wrapped in `withPublicContext` ALS frame                                                                                                                                 |
| **W2.12 — Subject editor wiring**                      | 8/8 templates honor `overrides.subject`; 6 use `?.trim()`, 2 use equivalent ternary. Admin UI at `admin/email-templates/page.tsx`                                                                                                  |
| **W2.13 — Signature/footer keys**                      | Old `email_signature_html`/`email_footer_html` REMOVED; replaced by `branding_email_header_html`/`branding_email_footer_html`, wired through `port-config.ts:100-101`/`:549-550` → `shell.ts:31-32,44-45,69,71`                    |
| **W2.15 — Notification email XSS**                     | `notifications.ts:8` `safeUrl` import + `:12-19` `escapeHtml` + `:82` description escape + `:84` href safeUrl                                                                                                                      |
| **A.Phase 3 — Custom-doc upload-for-signing**          | `custom-document-upload.service.ts` + `api/v1/interests/[id]/upload-for-signing/route.ts` both shipped                                                                                                                             |
| **A.Phase 5 — Embedded signing URL + redirect**        | `documenso_redirect_url` end-to-end (registry → port-config:94/408 → documenso-client.ts:314,379 → payload:318); `EmbeddedSigningCard` + test endpoint shipped                                                                     |
| **A.Phase 7 — Project Director RBAC**                  | Registry `user-select` field type at `registry.ts:116-123,162-168`; rendered via `RegistryDrivenForm` in `admin/documenso/page.tsx:244-248`; webhook fires `document_signing_your_turn` in-CRM notification                        |
| **A.Risk #4 — v2 payload audit**                       | `payload.id: number \| string`; `resolveRecipientToken` covers `r.token`/`r.signingToken`/URL fallback (`route.ts:80-85`); doc lookup via `or(documensoId, documensoNumericId)`                                                    |
| **C.1 — Webhook port_id enforcement**                  | `resolveWebhookDocument` gates with `eq(documents.portId, portId)` (`documents.service.ts:1070-1078`); ambiguous cross-port match refuses mutation; `portScope` forwarded to every event handler                                   |
| **C.3 — v2 voidDocument shape**                        | Cleanly branches `v2 → DELETE /api/v2/envelope/{id}` vs `v1 → DELETE /api/v1/documents/{id}` (`documenso-client.ts:1286-1309`); idempotent on 404                                                                                  |
| **E.1 — Berth Waiting List + Maintenance tabs**        | Removed entirely; comment at `berth-tabs.tsx:449-452` documents the removal                                                                                                                                                        |
| **E.2 — Interest Contract + Reservation tabs**         | NOT "coming soon" — both fully wired with active-doc hero card, signers, signing progress, cancel/remind, upload-for-signing, mark-externally-signed                                                                               |
| **W3 — set-state-in-effect cleanup**                   | **0 of 40 remaining.** Entire phase already done.                                                                                                                                                                                  |
| **W6.bounce — IMAP bounce poller**                     | `imap-bounce-poller.ts` shipped; scheduled `*/15 * * * *` via `scheduler.ts:59`; no-op when IMAP env unset; state persisted in `system_settings.bounce_poller_state`                                                               |
| **G.4 — Tier-2 deps**                                  | All 12 installed (@sentry, @use-gesture, archiver, embla-carousel, next-intl, pdfjs-dist, react-number-format, react-resizable-panels, react-virtuoso, yet-another-react-lightbox, eslint, type-fest)                              |

### ❌ OUTSTANDING (15 items — real work needed)

Ranked by impact + remaining effort:

#### Tier 1 — security/data-integrity (ship before next deploy)

| Item                                        | Concrete gap                                                                                                                                                                                                                                                                                                                                                          | Effort    |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
| **A.7 — RTBF true wipe**                    | `client-hard-delete.service.ts` only nullifies FKs. Three gaps: (1) no UPDATE on `email_messages.body_html/body_text/subject`, (2) no UPDATE redacting `document_sends.recipient_email`, (3) `files.clientId` nulled but `storage.delete(key)` never called → orphaned blobs                                                                                          | **4h**    |
| **A.8 — `user_permission_overrides` FK**    | Schema declares `onDelete: 'cascade'` (`users.ts:319`), not `'set null'` as audit requires. Migration 0070 skips this table.                                                                                                                                                                                                                                          | **30min** |
| **W2.14 — PII redaction in error pipeline** | 6 of 13 PII keys NOT caught (firstName, lastName, dateOfBirth, city, postalCode, country) — masker fragments are snake_case but request bodies are camelCase, and the normalizer only swaps hyphens→underscores. Same gap also affects W2.10 (shared masker). A 5xx on `/api/v1/clients` with camelCase payload leaks these into `error_events.request_body_excerpt`. | **2h**    |

#### Tier 2 — Documenso completion (scope drastically reduced from original plan)

| Item                                              | Concrete gap                                                                                                                                                                                                                                                                                                                   | Effort                  |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- |
| **A.Phase 2 — completion_cc_emails distribution** | Column `documents.completionCcEmails` exists in schema (`documents.ts:103`). `handleDocumentCompleted` never reads it. Only piece of Phase 2 not done.                                                                                                                                                                         | **2h**                  |
| **A.Phase 4 — Auto-detect button wiring**         | `document-field-detector.ts` (298 lines, full regex anchor scanner with proximity-based recipient inference) AND `template-editor.tsx` (680 lines, full click-place/drag/resize/multi-page) both exist. Only missing: button in editor that POSTs PDF to a server action calling `detectFields()`, then pre-populates markers. | **3h**                  |
| **A.Phase 6 — Polish items**                      | All four missing: auto-send delay, expiration UI, reminder rate-limit display, failed-webhook recovery UI. Each independent.                                                                                                                                                                                                   | **2-3h each, optional** |
| **C.2 — `documentEvents.recipient_email` column** | Column doesn't exist; only `brochure_sends.recipient_email` exists. Needs migration + composite unique index for per-recipient dedup.                                                                                                                                                                                          | **1h**                  |
| **C.4 — `publicInterestService` extraction**      | Three public POST routes duplicate inline DB logic. Pure refactor, no active bug.                                                                                                                                                                                                                                              | **2h**                  |

#### Tier 3 — reporting/recommender test coverage (correctness validation)

| Item                                       | Concrete gap                                                                                                                                                                                          | Effort       |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| **W7.reports — Math snapshot tests**       | Zero coverage in `tests/unit/services/reports/` or equivalent. Only PDF byte smoke tests + E2E clickthrough. Revenue/funnel/occupancy math has never been hand-verified.                              | **half-day** |
| **W7.recommender — Tier-ladder snapshots** | Existing `tests/unit/services/berth-recommender.test.ts` has real edge-case coverage with plain `toBe`/`toBeCloseTo` assertions. Not snapshots — limits regression-catching for future weight tuning. | **2h**       |

#### Tier 4 — opportunistic / rolling

| Item                                           | Concrete gap                                                                                                                                                                                       | Effort                                   |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| **G.1 — `.toLocale*` migration**               | Count grew **93 → 105** sites since audit (more added than migrated). Helper at `format-date.ts` is scaffolded but call sites haven't moved.                                                       | **rolling, ~3h opportunistic**           |
| **G.2 — drizzle-zod adoption**                 | 4 of 40 validator files (10%). Most validators have heavy form transforms that don't 1:1 with table shape — adoption is bounded.                                                                   | **rolling, 30min/file**                  |
| **W6.attach — Attachment threshold UX gating** | Threshold logic + banner exist; **IMAP-gating of the banner** wasn't fully verified at the component level (CLAUDE.md claims it; not seen in send-dialog code). May be working — needs spot-check. | **15min spot-check; up to 1h if broken** |
| **D.1 — Per-user reminder schedule**           | TODO at `scheduler.ts:48` intact. Flat hourly cron only. Defer until customer asks (no concrete trigger).                                                                                          | **deferred**                             |
| **D.2 — CSV/Excel import worker**              | Worker file exists + registered with BullMQ, but handler is a no-op TODO. No enqueue calls anywhere. Defer (no concrete trigger).                                                                  | **deferred**                             |

## Revised execution plan — supersedes Phases A-F

**Tier 1 (5h total) → ship before next prod deploy.** A.7 + A.8 + W2.14.

**Tier 2 Documenso completion (8-10h, mostly optional polish):** A.Phase 2 completion_cc_emails (2h) + A.Phase 4 anchor button wiring (3h) + C.2 recipient_email column (1h) + C.4 service extraction (2h, refactor). A.Phase 6 polish items defer until a customer needs them.

**Tier 3 reporting/recommender validation (6h):** Build snapshot fixtures for revenue/funnel/occupancy math + add snapshot variants of recommender tier-ladder tests.

**Tier 4 rolling:** G.1 and G.2 migrate opportunistically as files are touched. W6.attach 15-min spot-check. D.1 and D.2 stay deferred.

**Total real outstanding work: ~19-25h (~3 focused days).** Down from the original 4-week estimate.

## Commit prefixes for this round

- `fix(audit-wave-1): A.7 RTBF true wipe`
- `fix(audit-wave-1): A.8 user_permission_overrides set-null FK`
- `fix(audit-wave-2): W2.14 PII redaction camelCase coverage`
- `feat(documenso-p2): completion_cc_emails distribution`
- `feat(documenso-p4): anchor detector + auto-place button`
- `feat(audit-c): documentEvents.recipient_email + per-recipient dedup`
- `refactor(audit-c): extract publicInterestService`
- `test(audit-w7): reporting math snapshot fixtures`
- `test(audit-w7): recommender tier-ladder snapshots`

## Verification team config (for reference / re-use)

Sweep was 5 agents (originally), Sonnet 1M with explicit `model:` param, narrow scope (4-5 items each), pre-quoted evidence where possible:

- `wave1-finisher` — A.3/A.6/A.7/A.8
- `wave2-finisher` — W2.10/W2.11/W2.14/W2.15
- `documenso-verifier` — A.Phase 2/4/5/6/7 + Risk #4 + C.1/C.3
- `misc-verifier` — D.1/D.2 + E.1/E.2 + W3 + W6.bounce + W6.attach + W7.reports + W7.recommender
- `reverify-wave1-quickwins` + `reverify-wave2-and-c` + `reverify-phase3-and-g` — re-did 13 items fresh (per user request to discard inline pre-digest)

Lessons captured in `~/.claude/projects/-Users-matt-Repos-new-pn-crm/memory/feedback_subagent_context_bloat.md`.
