14 KiB
phase, verified, status, score, re_verification
| phase | verified | status | score | re_verification |
|---|---|---|---|---|
| 15-multi-signer-backend | 2026-04-03T00:00:00Z | passed | 17/17 must-haves verified | 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
completionTriggeredAtUPDATE-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)