The existing system is a clean, well-factored single-signer flow. Every document has exactly one signing token, one recipient, and one atomic "mark used" operation. Multi-signer requires four categories of change:
1.**Schema:** Tag fields to signers, expand signingTokens to identify whose token it is, replace the single-recipient model with a per-signer recipients structure.
2.**Completion detection:** Replace the single `usedAt` → `status = 'Signed'` trigger with a "all tokens claimed" check after each signing submission.
3.**Final PDF assembly:** The per-signer merge model (each signer embeds into the same prepared PDF, sequentially via advisory lock) accumulates signatures into `signedFilePath`. A completion pass fires after the last signer claims their token.
4.**Migration:** Existing signed documents must remain intact — achieved by treating the absence of `signerEmail` on a field as "legacy single-signer" (same as the existing `type` coalescing pattern already used in `getFieldType`).
Docker deployment has a distinct failure mode from local dev: **env vars that exist in `.env.local` are absent in the Docker container unless explicitly provided at runtime.** The email-sending failure in production Docker is caused by `CONTACT_SMTP_HOST`, `CONTACT_EMAIL_USER`, and `CONTACT_EMAIL_PASS` never reaching the container. The fix is `env_file` injection at `docker compose up` time, not Docker Secrets (which mount as files, not env vars, and require app-side entrypoint shim code that adds no security benefit for a single-server deployment).
**Backward compatibility:** The `signerEmail` field is optional. Existing documents stored in `signature_fields` JSONB have no `signerEmail`. The signing page already filters fields via `isClientVisibleField()`. A new `getSignerEmail(field, fallbackEmail)` helper mirrors `getFieldType()` and returns `field.signerEmail ?? fallbackEmail` — where `fallbackEmail` is the document's legacy single-recipient email. This keeps existing signed documents working without a data backfill.
**Why not store field IDs in the token?** Field filtering should happen server-side by matching `field.signerEmail === tokenRow.signerEmail`. Storing field IDs in the token creates a second source of truth and complicates migration. The signing GET endpoint already fetches the document's `signatureFields` and filters them — adding a `signerEmail` comparison is a one-line change.
**Backward compatibility:** `signer_email` is nullable. Existing tokens have `null`. The signing endpoint uses `tokenRow.signerEmail` to filter fields; `null` falls back to `isClientVisibleField()` (current behavior).
**Current problem:** `assignedClientId` is a single-value text column. Signers in multi-signer are identified by email, not necessarily by a `clients` row (requirement: "signers may not be in clients table"). The current `emailAddresses` JSONB column holds email strings but lacks per-signer identity (name, signing status, token linkage).
**Why JSONB array instead of a new table?** A `document_signers` join table would be cleanest long-term, but for a solo-agent app with document-level granularity and no need to query "all documents this email signed across the system", JSONB avoids an extra join on every document fetch. The `tokenJti` field on each signer record gives the bidirectional link without the join table.
**Why keep `assignedClientId`?** It is still used by the send route to resolve the `clients` row for `client.email` and `client.name`. For multi-signer, the agent provides emails directly in the `signers` array. The two flows coexist:
- Legacy: `assignedClientId` is set, `signers` is null → single-signer behavior
- New: `signers` is set (non-null, non-empty) → multi-signer behavior
**`emailAddresses` column:** Currently stores `[client.email, ...ccAddresses]`. In multi-signer this is superseded by `signers[].email`. The column can remain and be ignored for new documents, or populated with all signer emails for audit reading consistency.
**Backward compatibility:** Postgres enums cannot have values removed, only added. The existing `email_sent` and `signature_submitted` events stay in the enum and continue to be fired for legacy single-signer documents. New multi-signer documents fire the new, more specific events. Adding values to a Postgres enum requires raw SQL that Drizzle cannot auto-generate:
**Important:** In Postgres 12+, `ALTER TYPE ... ADD VALUE` can run inside a transaction. In Postgres < 12 it cannot. Write migration with `-- statement-breakpoint` between each ALTER to prevent Drizzle from wrapping them in a single transaction.
| `status` | Draft → Sent → Viewed → Signed | "Signed" now means ALL signers complete. Status transitions to Signed only after `document_completed` fires. |
| `signedAt` | Timestamp of the single signing | Timestamp of completion (last signer claimed) — same semantic, set later. |
| `signedFilePath` | Path to the merged signed PDF | Accumulator path — updated by each signer as they embed; final value = completed PDF. |
| `pdfHash` | SHA-256 of signed PDF | Same — hash of the final merged PDF. |
**Chicken-and-egg consideration:** The agent must know the signer list before tagging fields. Resolution: the PreparePanel collects signer emails first (a new multi-signer entry UI replaces the single email textarea). These are saved to `documents.signers` via `PUT /api/documents/[id]/signers`. The FieldPlacer palette then offers a signer email selector when placing a client-visible field.
**Unassigned client fields:** If `signerEmail` is absent on a client-visible field in a multi-signer document, behavior must be defined. Recommended: block sending until all client-signature and initials fields have a `signerEmail`. The UI shows a warning. Text, checkbox, and date fields do not require a signer tag (they are embedded at prepare time and never shown to signers).
The `signerEmail` field is absent from all existing `signatureFields` JSONB. For existing tokens, `signer_email = null`. The signing endpoint's null path falls through to `isClientVisibleField()` — identical to current behavior. Existing documents never enter multi-signer code paths.
| `PreparePanel` | `src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx` | Multi-signer list entry UI (replaces single textarea) |
| `FieldPlacer` | `src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx` | Signer email selector on field place; per-signer color coding |
| `signing-mailer.tsx` | `src/lib/signing/signing-mailer.tsx` | Add `sendAllSignersCompletionEmail` function |
The existing email failure in Docker production is a **runtime env var injection gap**: `CONTACT_SMTP_HOST`, `CONTACT_EMAIL_USER`, `CONTACT_EMAIL_PASS` (and `CONTACT_SMTP_PORT`) exist in `.env.local` during development but are never passed to the Docker container at runtime. The nodemailer transporter in `src/lib/signing/signing-mailer.tsx` reads these directly from `process.env`. When they are undefined, `nodemailer.createTransport()` silently creates a transporter with no credentials, and `sendMail()` fails at send time.
Docker Compose's `secrets:` block mounts secret values as files at `/run/secrets/<name>` inside the container. This is designed for Docker Swarm and serves a specific security model (encrypted transport in Swarm, no env var exposure in `docker inspect`). It requires the application to read from the filesystem instead of `process.env`. Bridging this to `process.env` requires an entrypoint shell script that reads each `/run/secrets/` file and exports its value before starting `node server.js`.
For this deployment (single VPS, secrets are SSH-managed files on the server, not Swarm), the file-based secrets approach adds complexity with no meaningful security benefit over a properly permissioned `.env.production` file. **Use `env_file` injection, not `secrets:`.**
**Decision:** Confirmed approach is `env_file:` with a server-side `.env.production` file (not committed to git, permissions 600, owned by deploy user).
| Category | Prefix | When Evaluated | Who Can Read | Example |
|---|---|---|---|---|
| Server-only runtime | (none) | At request time, via `process.env` | API routes, Server Components, Route Handlers | `DATABASE_URL`, `CONTACT_SMTP_HOST`, `OPENAI_API_KEY` |
| Public build-time | `NEXT_PUBLIC_` | At `next build` — inlined into JS bundle | Client-side code | (none in this app) |
This app has **no `NEXT_PUBLIC_` variables.** All secrets are server-only and evaluated at request time. They do not need to be present at `docker build` time — only at `docker run` / `docker compose up` time. This is the ideal case: the same Docker image can run in any environment by providing different `env_file` values.
**Verified:** Next.js App Router server-side code (`process.env.X` in API routes, Server Components) reads env vars at request time when the route is dynamically rendered. Source: [Next.js deploying docs](https://nextjs.org/docs/app/getting-started/deploying), [vercel/next.js docker-compose example](https://github.com/vercel/next.js/tree/canary/examples/with-docker-compose).
The current `next.config.ts` does not set `output: 'standalone'`. The standalone output is required for the official multi-stage Docker pattern — it produces a self-contained `server.js` with only necessary files, yielding a ~60-80% smaller production image compared to copying all of `node_modules`.
**Caution:** `@napi-rs/canvas` is a native addon. Verify the production base image (Debian slim recommended, not Alpine) has the required glibc version. Alpine uses musl libc which is incompatible with pre-built `@napi-rs/canvas` binaries. The official canary Dockerfile uses `node:24-slim` (Debian).
### Dockerfile — Recommended Pattern
Three-stage build based on the [official Next.js canary example](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile):
**No `ARG`/`ENV` lines for secrets in the Dockerfile.** Secrets are never baked into the image. They arrive exclusively at runtime via `env_file:` in the Compose file.
`wget` is available in the `node:20-slim` (Debian) image. `curl` is not installed by default in slim images; `wget` is more reliable without a separate `RUN apt-get install curl`.
Or more practically: run `npx drizzle-kit migrate` from the host with `DATABASE_URL` set in the shell, pointing at the production Neon database, before deploying the new container. This avoids needing `drizzle-kit` inside the production image.
| Two signers submit simultaneously, both read same PDF | HIGH | Postgres advisory lock `pg_advisory_xact_lock(hashtext(documentId))` on signing POST |
| Accumulator path tracking lost between signers | MEDIUM | `documents.signedFilePath` always tracks current accumulator; null = use preparedFilePath |
| Agent sends before all fields tagged to signers | MEDIUM | PreparePanel validates: block send if any client-visible field has no `signerEmail` in a multi-signer document |
| `ALTER TYPE ADD VALUE` in Postgres < 12 fails in transaction | MEDIUM | Use `-- statement-breakpoint` between each ALTER; verify Postgres version |
| Resending to a signer (token expired) | LOW | Issue new token via a resend endpoint; existing tokens remain valid |
| Legacy documents break | LOW | `signerEmail` optional at every layer; null path = unchanged behavior throughout |
- [Docker Compose Secrets — Official Docs](https://docs.docker.com/compose/how-tos/use-secrets/) — HIGH confidence
- [Next.js with-docker official example — Dockerfile (canary)](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile) — HIGH confidence
- [Next.js with-docker-compose official example (canary)](https://github.com/vercel/next.js/tree/canary/examples/with-docker-compose) — HIGH confidence
- [Next.js env var classification (runtime vs build-time)](https://nextjs.org/docs/pages/guides/environment-variables) — HIGH confidence
- Direct codebase inspection: `src/lib/signing/signing-mailer.tsx`, `src/lib/db/schema.ts`, `.env.local` key names, `next.config.ts` — HIGH confidence