--- phase: 15-multi-signer-backend verified: 2026-04-03T00:00:00Z status: passed score: 17/17 must-haves verified re_verification: false --- # Phase 15: Multi-Signer Backend Verification Report **Phase Goal:** The server correctly creates one signing token per signer, filters each signer's signing page to their own fields, and detects completion atomically — all signers receive their links at once, and when the last signer signs all parties get the final PDF. **Verified:** 2026-04-03 **Status:** passed **Re-verification:** No — initial verification --- ## Goal Achievement ### Observable Truths | # | Truth | Status | Evidence | |---|-------|--------|----------| | 1 | `createSigningToken` accepts optional `signerEmail` and writes it to DB | VERIFIED | `token.ts:7` signature `(documentId: string, signerEmail?: string)`, `token.ts:22` `signerEmail: signerEmail ?? null` in DB insert | | 2 | `createSignerDownloadToken` produces a 72h JWT with purpose `signer-download` | VERIFIED | `token.ts:69-75` — `setExpirationTime('72h')`, `purpose: 'signer-download'` | | 3 | `verifySignerDownloadToken` validates purpose claim and returns `documentId` | VERIFIED | `token.ts:77-81` — checks `payload['purpose'] !== 'signer-download'`, returns `{ documentId }` | | 4 | `sendSignerCompletionEmail` sends plain-text email with download link | VERIFIED | `signing-mailer.tsx:69-86` — correct opts shape, subject "Signed copy ready: {name}", text with downloadUrl | | 5 | `GET /api/sign/download/[token]` serves signed PDF for valid signer-download tokens | VERIFIED | `download/[token]/route.ts:12-46` — public GET, verifies token, queries DB, returns PDF bytes | | 6 | Download route rejects expired tokens, non-Signed documents, and path traversal | VERIFIED | `route.ts:21-23` 401 on invalid token; `30-32` 404 on non-Signed/no-path; `35-37` 403 on path traversal | | 7 | When `documents.signers` is populated, one signing token is created per signer with that signer's email | VERIFIED | `send/route.ts:31-50` — branches on `doc.signers.length > 0`, calls `createSigningToken(doc.id, signer.email)` per signer | | 8 | All signing request emails are dispatched in parallel via `Promise.all` | VERIFIED | `send/route.ts:34-50` — `Promise.all(signers.map(async (signer) => { ... }))` | | 9 | When `documents.signers` is null/empty, existing single-signer behavior is preserved | VERIFIED | `send/route.ts:51-67` — else branch: legacy `assignedClientId ?? clientId` path, `createSigningToken(doc.id)` without signerEmail | | 10 | The signing URL uses `APP_BASE_URL` (not `NEXT_PUBLIC_BASE_URL`) | VERIFIED | `send/route.ts:29` `process.env.APP_BASE_URL ?? 'http://localhost:3000'`; no `NEXT_PUBLIC_BASE_URL` reference in file | | 11 | Document status is set to `Sent` after all emails dispatch successfully | VERIFIED | `send/route.ts:70-75` — `if (doc.status === 'Draft')` updates to `'Sent'` after both paths | | 12 | Each signer gets an audit event `email_sent` with `metadata.signerEmail` | VERIFIED | `send/route.ts:44-48` — `logAuditEvent({ eventType: 'email_sent', metadata: { signerEmail: signer.email } })` | | 13 | GET handler returns only fields where `field.signerEmail` matches `tokenRow.signerEmail` | VERIFIED | `sign/[token]/route.ts:90-98` — two-branch filter: `tokenRow.signerEmail !== null` gates `field.signerEmail === tokenRow.signerEmail` | | 14 | GET handler returns all `isClientVisibleField` fields for legacy null-signer tokens | VERIFIED | `sign/[token]/route.ts:97` — `return true` when `tokenRow.signerEmail === null` | | 15 | POST handler scopes date field stamping and signable fields to this signer only | VERIFIED | `sign/[token]/route.ts:199-204` date scope, `235-240` signable fields scope — both check `signerEmail !== null` before filtering by `f.signerEmail === signerEmail` | | 16 | POST handler accumulates partial PDFs (JTI-keyed) and detects completion atomically | VERIFIED | `route.ts:187` `_partial_${payload.jti}.pdf`; `route.ts:303-315` `UPDATE … WHERE completionTriggeredAt IS NULL RETURNING` | | 17 | Completion winner sends agent notification and all signer completion emails with download links | VERIFIED | `route.ts:332-388` — fire-and-forget blocks gated behind `won.length > 0`; `sendAgentNotificationEmail` + `sendSignerCompletionEmail` to all signers; `createSignerDownloadToken` constructs download URL | **Score:** 17/17 truths verified --- ### Required Artifacts | Artifact | Expected | Status | Details | |----------|----------|--------|---------| | `teressa-copeland-homes/src/lib/signing/token.ts` | Extended `createSigningToken` + signer-download token pair | VERIFIED | All 6 exported functions present: `createSigningToken`, `verifySigningToken`, `createDownloadToken`, `verifyDownloadToken`, `createAgentDownloadToken`, `verifyAgentDownloadToken`, `createSignerDownloadToken`, `verifySignerDownloadToken` | | `teressa-copeland-homes/src/lib/signing/signing-mailer.tsx` | Signer completion email | VERIFIED | 3 email functions: `sendSigningRequestEmail`, `sendAgentNotificationEmail`, `sendSignerCompletionEmail` | | `teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts` | Public signer download route | VERIFIED | File exists, exports `GET`, no auth import, correct guards | | `teressa-copeland-homes/src/app/api/documents/[id]/send/route.ts` | Multi-signer send route with legacy fallback | VERIFIED | Multi-signer branch + legacy branch, `DocumentSigner` typed, `APP_BASE_URL` | | `teressa-copeland-homes/src/app/api/sign/[token]/route.ts` | Signer-aware GET + POST handlers with accumulate PDF and atomic completion | VERIFIED | Signer-aware field filter in GET; accumulate PDF, scoped date/sig fields, atomic `completionTriggeredAt` guard, completion notifications in POST | --- ### Key Link Verification | From | To | Via | Status | Details | |------|-----|-----|--------|---------| | `sign/download/[token]/route.ts` | `src/lib/signing/token.ts` | `verifySignerDownloadToken` import | WIRED | `route.ts:2` imports `verifySignerDownloadToken`, called at `route.ts:20` | | `sign/download/[token]/route.ts` | `documents` table | `db.query.documents.findFirst` | WIRED | `route.ts:25-28`, guards `status !== 'Signed'` at `route.ts:30` | | `send/route.ts` | `token.ts` | `createSigningToken(doc.id, signer.email)` | WIRED | `send/route.ts:6` import, `36` call with signerEmail | | `send/route.ts` | `signing-mailer.tsx` | `Promise.all` with `sendSigningRequestEmail` | WIRED | `send/route.ts:7` import, `34-50` Promise.all loop | | `sign/[token]/route.ts GET` | `documents.signatureFields` | filter by `tokenRow.signerEmail` | WIRED | `route.ts:46-48` fetches `tokenRow`, `90-98` filters on `tokenRow.signerEmail` | | `sign/[token]/route.ts POST` | `documents.completionTriggeredAt` | `UPDATE WHERE IS NULL RETURNING` atomic guard | WIRED | `route.ts:303-315` — `isNull(documents.completionTriggeredAt)` guard with `.returning()` | | `sign/[token]/route.ts POST` | `signing-mailer.tsx` | `sendSignerCompletionEmail` + `sendAgentNotificationEmail` | WIRED | `route.ts:12` imports both, called in winner block at `344`, `373` | | `sign/[token]/route.ts POST` | `token.ts` | `createSignerDownloadToken` | WIRED | `route.ts:3` import, `368` call in winner block | --- ### Data-Flow Trace (Level 4) | Artifact | Data Variable | Source | Produces Real Data | Status | |----------|---------------|--------|--------------------|--------| | `send/route.ts` | `doc.signers` | `db.query.documents.findFirst` | Yes — JSONB from DB | FLOWING | | `sign/[token]/route.ts GET` | `tokenRow.signerEmail` | `db.query.signingTokens.findFirst` | Yes — DB row | FLOWING | | `sign/[token]/route.ts POST` | `freshDoc.signedFilePath` | Re-fetch from DB at step 7 | Yes — DB row | FLOWING | | `sign/[token]/route.ts POST` | `remaining[0].cnt` | SQL `count(*)` on `signingTokens WHERE usedAt IS NULL` | Yes — live DB count | FLOWING | | `sign/[token]/route.ts POST` | `won` | `UPDATE … RETURNING` | Yes — returns row or empty array | FLOWING | --- ### Behavioral Spot-Checks | Behavior | Command | Result | Status | |----------|---------|--------|--------| | `token.ts` exports all 8 functions | `grep -c "^export async function" token.ts` | 8 | PASS | | `signer-download` purpose used in create and verify | `grep "signer-download" token.ts` | 3 lines (comment + create + verify) | PASS | | No auth import in download route (public) | `grep "import.*auth" download/[token]/route.ts` | 0 matches | PASS | | `status: 'Signed'` set exactly once in sign route | `grep -c "status: 'Signed'" sign/[token]/route.ts` | 1 | PASS | | `APP_BASE_URL` used; no `NEXT_PUBLIC_BASE_URL` in send route | `grep NEXT_PUBLIC_BASE_URL send/route.ts` | 0 matches | PASS | | `completionTriggeredAt` atomic guard present | `grep "isNull(documents.completionTriggeredAt)"` | Found at line 308 | PASS | | Signer completion emails fire only in winner block | Pattern gated behind `won.length === 0` check | Lines 357-388 inside `if (won.length === 0)` is NOT it — see note | PASS (see note) | **Note on winner block:** The `sendSignerCompletionEmail` and `sendAgentNotificationEmail` calls appear after line 315 (the `if (won.length === 0) return` early exit). Any code reached past that guard is winner-only. Code correctly gated. --- ### Requirements Coverage | Requirement | Source Plan | Description | Status | Evidence | |-------------|-------------|-------------|--------|---------| | MSIGN-05 | 15-02 | Document recipients list built automatically from unique signer emails on placed fields | SATISFIED | Send route reads `doc.signers` (JSONB written from field signerEmails in Phase 14); loops over each — no separate manual entry needed | | MSIGN-06 | 15-02 | All signers receive their unique signing links simultaneously when agent sends | SATISFIED | `Promise.all` in send route dispatches all signing emails concurrently | | MSIGN-07 | 15-03 | Each signer's signing page shows only their own assigned fields | SATISFIED | GET handler filters `signatureFields` by `tokenRow.signerEmail` | | MSIGN-10 | 15-01, 15-03 | When all signers complete, agent receives a notification email | SATISFIED | `sendAgentNotificationEmail` fires in completion winner block only | | MSIGN-11 | 15-01, 15-03 | When all signers complete, all parties receive the final merged PDF via email link | SATISFIED | `sendSignerCompletionEmail` fires for each `doc.signers` entry with `createSignerDownloadToken` URL; download route serves signed PDF | **Orphaned requirements check:** REQUIREMENTS.md traceability table maps exactly MSIGN-05, MSIGN-06, MSIGN-07, MSIGN-10, MSIGN-11 to Phase 15 — matches plan frontmatter declarations exactly. No orphaned requirements. **Out-of-scope for Phase 15 (correctly deferred to Phase 16):** MSIGN-01, MSIGN-02, MSIGN-03, MSIGN-04, MSIGN-08 (Phase 14), MSIGN-09. --- ### Anti-Patterns Found | File | Line | Pattern | Severity | Impact | |------|------|---------|----------|--------| | `sign/[token]/route.ts` | 263 | `console.error` | Info | Debug logging — non-blocking, appropriate error handler | | `send/route.ts` | 79 | `console.error` | Info | Error boundary logging — appropriate | | `sign/[token]/route.ts` | 329 | `process.env.APP_BASE_URL ?? 'http://localhost:3000'` | Info | Fallback to localhost acceptable for dev; production env must set `APP_BASE_URL` | No blockers or warnings. All `console.error` calls are in error handlers, not substituting for real implementation. No stubs, no placeholder returns, no hardcoded empty arrays passed to rendering. --- ### Human Verification Required #### 1. Multi-signer full ceremony end-to-end **Test:** Create a document with two signers in `documents.signers`. Call `POST /api/documents/[id]/send`. Open both signing links in separate browser sessions. Have signer A sign, then signer B sign. **Expected:** Agent receives one notification email. Both signer A and signer B receive a completion email with a download link. Downloading the PDF returns a document with both signers' signatures embedded. **Why human:** Requires live SMTP delivery, real browser signing sessions, and PDF visual inspection. #### 2. Atomic completion race condition **Test:** Simulate two concurrent POST submissions to `/api/sign/[token]` for the last two signers arriving within milliseconds of each other. **Expected:** Exactly one winner sets `completionTriggeredAt` and fires notifications; the other returns `{ ok: true }` without double-sending emails. **Why human:** Requires concurrent load-testing tooling (e.g., k6 or two concurrent requests); cannot be verified with grep or static analysis. #### 3. Accumulate PDF correctness **Test:** Have signer A sign fields on page 1; have signer B sign fields on page 2. Inspect the final signed PDF. **Expected:** Final PDF contains signer A's signature on page 1 AND signer B's signature on page 2 — accumulated correctly via the JTI-keyed partial path chain. **Why human:** PDF content inspection requires visual review of the output file. --- ### Gaps Summary No gaps found. All 17 must-haves are verified at all four levels (exists, substantive, wired, data-flowing). All 5 required requirement IDs (MSIGN-05, MSIGN-06, MSIGN-07, MSIGN-10, MSIGN-11) have clear implementation evidence in the codebase. The phase goal is achieved: - One signing token per signer is created with that signer's email stored in the DB - Each signer's GET returns only their own fields (filtered by `tokenRow.signerEmail`) - Completion is detected atomically via the `completionTriggeredAt` UPDATE-WHERE-IS-NULL-RETURNING race guard - All signers receive links simultaneously via `Promise.all` - When the last signer signs, the completion winner sends agent notification + signer download links Three items are flagged for human verification (live email delivery, concurrent race, PDF accumulation correctness) but no automated check can substitute for them. --- _Verified: 2026-04-03_ _Verifier: Claude (gsd-verifier)_