diff --git a/.planning/STATE.md b/.planning/STATE.md
index 922d396..8243f18 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,7 +3,7 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: unknown
-last_updated: "2026-03-20T04:04:15Z"
+last_updated: "2026-03-20T04:13:12.205Z"
progress:
total_phases: 4
completed_phases: 4
diff --git a/.planning/phases/04-pdf-ingest/04-VERIFICATION.md b/.planning/phases/04-pdf-ingest/04-VERIFICATION.md
new file mode 100644
index 0000000..e955037
--- /dev/null
+++ b/.planning/phases/04-pdf-ingest/04-VERIFICATION.md
@@ -0,0 +1,185 @@
+---
+phase: 04-pdf-ingest
+verified: 2026-03-19T00:00:00Z
+status: human_needed
+score: 13/13 must-haves verified
+re_verification: false
+human_verification:
+ - test: "PDF renders in browser — not blank"
+ expected: "PDF pages are visibly rendered on the document detail page at /portal/documents/{docId}"
+ why_human: "react-pdf canvas rendering cannot be verified by static code analysis; blank renders are a known runtime failure mode for worker misconfiguration"
+ - test: "Page navigation Prev/Next works on multi-page PDF"
+ expected: "Clicking Next advances to page 2; clicking Prev returns to page 1; buttons are disabled at boundaries"
+ why_human: "State transitions require a running browser; cannot verify via grep"
+ - test: "Zoom In / Zoom Out changes page size visibly"
+ expected: "PDF canvas grows/shrinks in browser on click"
+ why_human: "Visual behavior; cannot verify statically"
+ - test: "Download button downloads the PDF file"
+ expected: "Browser downloads the file rather than navigating away"
+ why_human: "Browser download behavior cannot be verified statically"
+ - test: "After adding document via modal, document appears in list without page reload"
+ expected: "router.refresh() causes new document row to appear in DocumentsTable while staying on the client profile page"
+ why_human: "router.refresh() behavior and UI update require a running browser session"
+ - test: "Modal search filters the forms list in real time"
+ expected: "Typing in the search box reduces the visible list to matching templates"
+ why_human: "Client-side filter state requires browser interaction to confirm"
+ - test: "Unauthenticated API access returns 401"
+ expected: "GET /api/forms-library and GET /api/documents/{id}/file return 'Unauthorized' without a session cookie"
+ why_human: "Auth middleware is wired correctly in code but end-to-end auth behavior requires a running server to confirm"
+---
+
+# Phase 4: PDF Ingest Verification Report
+
+**Phase Goal:** Agents can attach PDFs to client records, browse a seeded forms library, upload custom PDFs, and view documents in-browser with a PDF renderer.
+**Verified:** 2026-03-19
+**Status:** human_needed — all automated checks pass; 7 items require runtime/browser confirmation
+**Re-verification:** No — initial verification
+
+---
+
+## Goal Achievement
+
+### Observable Truths
+
+| # | Truth | Status | Evidence |
+|---|-------|--------|----------|
+| 1 | form_templates table exists with id, name, filename, createdAt, updatedAt columns | VERIFIED | schema.ts lines 26-32; migration 0002_wealthy_zzzax.sql CREATE TABLE confirmed |
+| 2 | documents table has filePath and formTemplateId columns | VERIFIED | schema.ts lines 43-44; migration ALTER TABLE confirms both columns |
+| 3 | npm run seed:forms reads seeds/forms/ and upserts into form_templates | VERIFIED | scripts/seed-forms.ts: readdir → filter .pdf → onConflictDoUpdate; package.json line 13 wires it |
+| 4 | seeds/forms/ directory is tracked in git | VERIFIED | seeds/forms/ directory exists; .gitkeep confirmed by SUMMARY |
+| 5 | GET /api/forms-library returns authenticated JSON list ordered by name | VERIFIED | forms-library/route.ts: auth() guard + db.select().from(formTemplates).orderBy(asc(name)) |
+| 6 | POST /api/documents copies seed PDF or writes custom upload, inserts documents row | VERIFIED | documents/route.ts: full multipart/JSON branching, copyFile + writeFile, db.insert().returning() |
+| 7 | GET /api/documents/[id]/file streams PDF bytes with path traversal protection | VERIFIED | documents/[id]/file/route.ts: startsWith(UPLOADS_BASE) guard + readFile + Content-Type: application/pdf |
+| 8 | Unauthenticated requests to all three API routes return 401 | VERIFIED (code) | All three routes: if (!session) return new Response('Unauthorized', { status: 401 }) — needs runtime confirm |
+| 9 | Client profile page has an 'Add Document' button that opens a modal | VERIFIED | ClientProfileClient.tsx line 77: "+ Add Document" button sets isAddDocOpen; line 92-94: conditional AddDocumentModal render |
+| 10 | Modal shows searchable forms library list; agent can filter by typing | VERIFIED | AddDocumentModal.tsx: fetch /api/forms-library on mount; filtered = templates.filter(... query ...) rendered in ul |
+| 11 | Modal has 'Browse files' option for custom PDF upload | VERIFIED | AddDocumentModal.tsx lines 100-103: labeled file input accept="application/pdf" |
+| 12 | Clicking a document name navigates to the document detail page | VERIFIED | DocumentsTable.tsx lines 48-54: Link href="/portal/documents/{row.id}" wraps each document name |
+| 13 | Document detail page renders PDF with page nav and zoom controls | VERIFIED (code) | PdfViewer.tsx: react-pdf Document+Page with Prev/Next buttons and scale state; PdfViewerWrapper dynamic-imports it; page.tsx wires PdfViewerWrapper with docId |
+
+**Score:** 13/13 truths verified (7 require runtime confirmation)
+
+---
+
+## Required Artifacts
+
+| Artifact | Expected | Status | Details |
+|----------|----------|--------|---------|
+| `teressa-copeland-homes/src/lib/db/schema.ts` | formTemplates table + extended documents | VERIFIED | formTemplates defined lines 26-32; formTemplateId + filePath on documents lines 43-44; documentsRelations + clientsRelations present |
+| `teressa-copeland-homes/scripts/seed-forms.ts` | Seed script for form_templates from seeds/forms/ | VERIFIED | Reads SEEDS_DIR, filters .pdf, upserts via onConflictDoUpdate — substantive, 37 lines |
+| `teressa-copeland-homes/seeds/forms/.gitkeep` | Tracked seed directory placeholder | VERIFIED | Directory exists (confirmed) |
+| `teressa-copeland-homes/drizzle/0002_wealthy_zzzax.sql` | Applied migration | VERIFIED | CREATE TABLE form_templates + ALTER TABLE documents with both new columns |
+| `teressa-copeland-homes/src/app/api/forms-library/route.ts` | Authenticated GET returning form templates | VERIFIED | Exports GET; auth guard + db query + Response.json — substantive |
+| `teressa-copeland-homes/src/app/api/documents/route.ts` | Authenticated POST for template copy and custom upload | VERIFIED | Exports POST; content-type branching; path traversal guard; file copy/write; db insert — 77 lines, substantive |
+| `teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts` | Authenticated GET PDF streaming | VERIFIED | Exports GET; auth guard; path traversal guard; readFile + Content-Type header — substantive |
+| `teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx` | Client component — searchable modal with file picker | VERIFIED | 'use client'; fetch on mount; filter state; submit handler posts to /api/documents; 137 lines, substantive |
+| `teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx` | Client component — react-pdf renderer with nav + zoom | VERIFIED | 'use client'; Document+Page; Prev/Next; Zoom In/Out; Download anchor; worker via import.meta.url — substantive |
+| `teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx` | Dynamic import wrapper for SSR safety | VERIFIED | dynamic(() => import('./PdfViewer'), { ssr: false }) — correct pattern |
+| `teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/page.tsx` | Document detail server page | VERIFIED | auth + redirect; db.query with { client: true }; PdfViewerWrapper render; back link — substantive |
+| `teressa-copeland-homes/next.config.ts` | transpilePackages for react-pdf + pdfjs-dist | VERIFIED | transpilePackages: ['react-pdf', 'pdfjs-dist'] confirmed |
+
+**Note:** The SUMMARY documented PdfViewer.tsx as the only component in the `_components/` directory. The actual implementation added `PdfViewerWrapper.tsx` as a dynamic-import thin wrapper (correct SSR pattern for react-pdf). Both files exist and are wired correctly. This is an enhancement over the plan, not a gap.
+
+---
+
+## Key Link Verification
+
+| From | To | Via | Status | Details |
+|------|----|-----|--------|---------|
+| `scripts/seed-forms.ts` | `seeds/forms/` | `readdir` | WIRED | Line 10: `const files = await readdir(SEEDS_DIR)` where SEEDS_DIR = `path.join(process.cwd(), 'seeds', 'forms')` |
+| `scripts/seed-forms.ts` | `formTemplates` DB table | `onConflictDoUpdate` | WIRED | Lines 26-30: `db.insert(formTemplates).values(...).onConflictDoUpdate({ target: formTemplates.filename, set: {...} })` |
+| `src/app/api/documents/route.ts` | `uploads/clients/{clientId}/` | `copyFile + mkdir` | WIRED | Lines 9, 42-44, 51, 64: UPLOADS_DIR + mkdir(destDir) + copyFile/writeFile |
+| `src/app/api/documents/[id]/file/route.ts` | `uploads/` | `readFile after startsWith guard` | WIRED | Lines 8, 24-27: UPLOADS_BASE + filePath.startsWith(UPLOADS_BASE) guard + readFile |
+| `AddDocumentModal.tsx` | `/api/forms-library` | `fetch on mount` | WIRED | Lines 18-22: `useEffect(() => { fetch('/api/forms-library').then(r => r.json()).then(setTemplates) }, [])` |
+| `AddDocumentModal.tsx` | `/api/documents` | `fetch POST on submit` | WIRED | Lines 52-58: both multipart and JSON POST paths call `fetch('/api/documents', { method: 'POST', ... })` |
+| `PdfViewer.tsx` | `/api/documents/{docId}/file` | `react-pdf Document file prop` | WIRED | Lines 49, 58: Download href + Document file prop both use `/api/documents/${docId}/file` |
+| `ClientProfileClient.tsx` | `AddDocumentModal` | `conditional render + state` | WIRED | Lines 9, 30, 77-79, 92-94: import + isAddDocOpen state + button onClick + conditional render |
+| `DocumentsTable.tsx` | `/portal/documents/{id}` | `Link href` | WIRED | Lines 48-54: `{row.name}` |
+| `page.tsx (docId)` | `PdfViewerWrapper` | `import + render` | WIRED | Line 7: import PdfViewerWrapper; line 41: `` |
+
+---
+
+## Requirements Coverage
+
+| Requirement | Source Plans | Description | Status | Evidence |
+|-------------|-------------|-------------|--------|----------|
+| DOC-01 | 04-01, 04-02, 04-03 | Agent can browse and import PDF forms from vendor API / manual upload | SATISFIED | Forms library API + AddDocumentModal + seeded templates fulfill the manual-upload fallback; DOC-01 open question (vendor API) resolved as manual upload in research |
+| DOC-02 | 04-01, 04-02 | Forms library syncs at least monthly | SATISFIED | `npm run seed:forms` provides idempotent monthly sync via `onConflictDoUpdate` on filename; monthly re-run workflow documented |
+| DOC-03 | 04-03 | Agent can view an imported PDF document in the browser | SATISFIED (code) | PdfViewer + PdfViewerWrapper + document detail page wired end-to-end; runtime rendering requires human confirmation |
+
+All three phase-4 requirement IDs (DOC-01, DOC-02, DOC-03) are accounted for. No orphaned requirements found. REQUIREMENTS.md traceability table marks all three as Complete at Phase 4.
+
+---
+
+## Anti-Patterns Found
+
+| File | Line | Pattern | Severity | Impact |
+|------|------|---------|----------|--------|
+| `AddDocumentModal.tsx` | 76, 118 | `placeholder="..."` attributes | Info | HTML input placeholder attributes — not stub indicators; legitimate UX copy |
+
+No blockers or warnings found. The only "placeholder" matches are standard HTML input placeholder text.
+
+---
+
+## Human Verification Required
+
+### 1. PDF Renders in Browser (Not Blank)
+
+**Test:** Navigate to a client profile, add a document (either from library after seeding a PDF, or via file picker). Click the document name. Observe the document detail page.
+**Expected:** The PDF pages are visibly rendered as canvas elements. The page counter reads "1 / N" where N is the actual page count.
+**Why human:** react-pdf canvas rendering depends on the pdfjs worker being correctly loaded at runtime. A misconfigured worker silently produces a blank canvas. This cannot be detected by static code analysis.
+
+### 2. Page Navigation Controls Work
+
+**Test:** On a multi-page PDF, click the "Next" button repeatedly and then "Prev".
+**Expected:** Page counter advances and retreats; "Prev" is disabled on page 1; "Next" is disabled on the last page.
+**Why human:** State transitions require a running browser.
+
+### 3. Zoom Controls Work
+
+**Test:** Click "Zoom In" and "Zoom Out" on the document detail page.
+**Expected:** The PDF canvas grows and shrinks proportionally. Scale respects the 0.4–3.0 bounds.
+**Why human:** Visual behavior; cannot verify statically.
+
+### 4. Download Button Downloads the File
+
+**Test:** Click the "Download" button on the document detail page.
+**Expected:** Browser initiates a file download (PDF lands in Downloads folder) rather than navigating to the file URL.
+**Why human:** Browser download behavior (``) cannot be verified statically, and depends on correct Content-Type header from the server.
+
+### 5. Document Appears in List After Modal Close
+
+**Test:** Open the "Add Document" modal from a client profile, submit a document, observe the documents section.
+**Expected:** The modal closes and the new document row appears in the DocumentsTable without a full page reload.
+**Why human:** `router.refresh()` behavior and client-side state update require a running browser session.
+
+### 6. Modal Search Filters in Real Time
+
+**Test:** Open the "Add Document" modal after seeding at least two templates. Type partial text in the search box.
+**Expected:** The forms list filters to only matching templates. Clearing the input restores the full list.
+**Why human:** Client-side filter state requires browser interaction.
+
+### 7. Unauthenticated API Access Returns 401
+
+**Test:** In an incognito browser window (no session cookie), navigate to `http://localhost:3000/api/forms-library` and `http://localhost:3000/api/documents/fake-id/file`.
+**Expected:** Both return "Unauthorized" (not a JSON list or PDF bytes).
+**Why human:** Auth middleware behavior requires a running server to confirm end-to-end.
+
+---
+
+## Summary
+
+All 13 observable truths are VERIFIED at the code level. All key links are WIRED. All three requirement IDs (DOC-01, DOC-02, DOC-03) are satisfied. No stub implementations, missing artifacts, or anti-pattern blockers were found.
+
+The phase delivered:
+- A complete database data layer (form_templates table, documents extended, migration applied, seed script wired)
+- Three authenticated API routes (forms library, document creation, PDF streaming) with path traversal protection
+- A full UI flow (AddDocumentModal with library search and file picker, DocumentsTable with document links, PdfViewer with react-pdf page nav and zoom, document detail page with back link and auth)
+- react-pdf v9 correctly configured with `transpilePackages` and `import.meta.url` worker (no CDN dependency)
+
+The 7 human verification items are all runtime/visual behaviors that cannot be tested statically. The code is correctly wired for all of them. The main risk is the react-pdf canvas render (item 1) which is the most common failure mode for pdfjs-dist in Next.js App Router setups.
+
+---
+
+_Verified: 2026-03-19_
+_Verifier: Claude (gsd-verifier)_