diff --git a/.planning/phases/02-planning-threads/02-RESEARCH.md b/.planning/phases/02-planning-threads/02-RESEARCH.md
new file mode 100644
index 0000000..5105c89
--- /dev/null
+++ b/.planning/phases/02-planning-threads/02-RESEARCH.md
@@ -0,0 +1,606 @@
+# Phase 2: Planning Threads - Research
+
+**Researched:** 2026-03-15
+**Domain:** Planning thread CRUD, candidate management, thread resolution with collection integration
+**Confidence:** HIGH
+
+## Summary
+
+Phase 2 extends the established Phase 1 stack (Hono + Drizzle + React + TanStack Router/Query) with two new database tables (`threads` and `thread_candidates`), corresponding service layers, API routes, and frontend components. The core architectural challenge is the thread resolution flow: when a user picks a winning candidate, the system must atomically create a collection item from the candidate's data and archive the thread.
+
+The existing codebase provides strong reuse opportunities. Candidates share the exact same fields as collection items (name, weight, price, category, notes, product link, image), so the `ItemForm`, `ItemCard`, `SlideOutPanel`, `ConfirmDialog`, `CategoryPicker`, and `ImageUpload` components can all be reused or lightly adapted. The service layer pattern (DB injection, Drizzle queries) and API route pattern (Hono + Zod validation) are well-established from Phase 1 and should be replicated exactly.
+
+Navigation is tab-based: "My Gear" and "Planning" tabs within the same page structure. TanStack Router supports this via either search params or nested routes. The thread list is the "Planning" tab; clicking a thread navigates to a thread detail view showing its candidates.
+
+**Primary recommendation:** Follow Phase 1 patterns exactly. New tables for threads and candidates, new service/route/hook layers mirroring items. Resolution is a single transactional operation in the thread service that creates an item and archives the thread.
+
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- Card-based layout, same visual pattern as collection items
+- Thread card shows: name prominent, then pill/chip tags for candidate count, creation date, price range
+- Flat list, most recent first (no grouping)
+- Resolved/archived threads hidden by default with a toggle to show them
+- Candidates displayed as card grid within a thread (same card style as collection items)
+- Slide-out panel for adding/editing candidates (reuses existing SlideOutPanel component)
+- Candidates share the exact same fields as collection items: name, weight, price, category, notes, product link, image
+- Same data shape means resolution is seamless -- candidate data maps directly to a collection item
+- Picking a winner auto-creates a collection item from the candidate's data (no review/edit step)
+- Confirmation dialog before resolving ("Pick [X] as winner? This will add it to your collection.")
+- After resolution, thread is archived (removed from active list, kept in history)
+- Confirmation dialog reuses the existing ConfirmDialog component pattern
+- Tab within the collection page: "My Gear" | "Planning" tabs
+- Top navigation bar always visible for switching between major sections
+- Thread list and collection share the same page with tab-based switching
+
+### Claude's Discretion
+- Exact "pick winner" UX (button on card vs thread-level action)
+- Thread detail page layout (how the thread view is structured beyond the card grid)
+- Empty state for threads (no threads yet) and empty thread (no candidates yet)
+- How the tab switching integrates with TanStack Router (query params vs nested routes)
+- Thread card image (first candidate's image, thread-specific image, or none)
+
+### Deferred Ideas (OUT OF SCOPE)
+- Linking existing collection items as reference candidates in a thread -- nice-to-have, not v1
+- Side-by-side comparison view (columns instead of cards) -- could be v2 enhancement (THRD-05)
+- Status tracking on candidates (researching -> ordered -> arrived) -- v2 (THRD-06)
+- Impact preview showing how a candidate affects setup weight/cost -- v2 (THRD-08)
+
+
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|-----------------|
+| THRD-01 | User can create a planning thread with a name | New `threads` table, thread service `createThread()`, POST /api/threads endpoint, thread creation UI (inline input or slide-out) |
+| THRD-02 | User can add candidate products to a thread with weight, price, notes, and product link | New `thread_candidates` table with same fields as items + threadId FK, candidate service, POST /api/threads/:id/candidates, reuse ItemForm with minor adaptations |
+| THRD-03 | User can edit and remove candidates from a thread | PUT/DELETE /api/threads/:threadId/candidates/:id, reuse SlideOutPanel + adapted ItemForm for edit, ConfirmDialog pattern for delete |
+| THRD-04 | User can resolve a thread by picking a winner, which moves to their collection | `resolveThread()` service function: transactionally create item from candidate data + set thread status to "resolved", ConfirmDialog for confirmation, cache invalidation for both threads and items queries |
+
+
+
+## Standard Stack
+
+### Core (Already Installed from Phase 1)
+| Library | Version | Purpose | Phase 2 Usage |
+|---------|---------|---------|---------------|
+| Hono | 4.12.x | Backend API | New thread + candidate route handlers |
+| Drizzle ORM | 0.45.x | Database ORM | New table definitions, migration, transactional resolution |
+| TanStack Router | 1.x | Client routing | Tab navigation, thread detail route |
+| TanStack Query | 5.x | Server state | useThreads, useCandidates hooks |
+| Zustand | 5.x | UI state | Thread panel state, confirm dialog state |
+| Zod | 4.x | Validation | Thread and candidate schemas |
+| @hono/zod-validator | 0.7.6+ | Route validation | Validate thread/candidate request bodies |
+
+### No New Dependencies Required
+
+Phase 2 uses the exact same stack as Phase 1. No new libraries needed.
+
+## Architecture Patterns
+
+### New Files Structure
+```
+src/
+ db/
+ schema.ts # ADD: threads + thread_candidates tables
+ shared/
+ schemas.ts # ADD: thread + candidate Zod schemas
+ types.ts # ADD: Thread, Candidate types
+ server/
+ index.ts # ADD: mount thread routes
+ routes/
+ threads.ts # NEW: /api/threads CRUD + resolution
+ services/
+ thread.service.ts # NEW: thread + candidate business logic
+ client/
+ routes/
+ index.tsx # MODIFY: add tab navigation, move collection into tab
+ threads/
+ index.tsx # NEW: thread detail view (or use search params)
+ components/
+ ThreadCard.tsx # NEW: thread card for thread list
+ CandidateCard.tsx # NEW: candidate card (adapts ItemCard pattern)
+ CandidateForm.tsx # NEW: candidate add/edit form (adapts ItemForm)
+ ThreadTabs.tsx # NEW: tab switcher component
+ hooks/
+ useThreads.ts # NEW: thread CRUD hooks
+ useCandidates.ts # NEW: candidate CRUD + resolution hooks
+ stores/
+ uiStore.ts # MODIFY: add thread-specific panel/dialog state
+tests/
+ helpers/
+ db.ts # MODIFY: add threads + candidates table creation
+ services/
+ thread.service.test.ts # NEW: thread + candidate service tests
+ routes/
+ threads.test.ts # NEW: thread API integration tests
+```
+
+### Pattern 1: Database Schema for Threads and Candidates
+
+**What:** Two new tables -- `threads` for the planning thread metadata and `thread_candidates` for candidate products within a thread. Candidates mirror the items table structure for seamless resolution.
+
+**Why this shape:** Candidates have the exact same fields as items (per CONTEXT.md locked decision). This makes resolution trivial: copy candidate fields to create a new item. The `status` field on threads supports the active/resolved lifecycle.
+
+```typescript
+// Addition to src/db/schema.ts
+export const threads = sqliteTable("threads", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ name: text("name").notNull(),
+ status: text("status").notNull().default("active"), // "active" | "resolved"
+ resolvedCandidateId: integer("resolved_candidate_id"), // FK set on resolution
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull()
+ .$defaultFn(() => new Date()),
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
+ .$defaultFn(() => new Date()),
+});
+
+export const threadCandidates = sqliteTable("thread_candidates", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ threadId: integer("thread_id").notNull()
+ .references(() => threads.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ weightGrams: real("weight_grams"),
+ priceCents: integer("price_cents"),
+ categoryId: integer("category_id").notNull()
+ .references(() => categories.id),
+ notes: text("notes"),
+ productUrl: text("product_url"),
+ imageFilename: text("image_filename"),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull()
+ .$defaultFn(() => new Date()),
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
+ .$defaultFn(() => new Date()),
+});
+```
+
+**Key decisions:**
+- `onDelete: "cascade"` on threadId FK: deleting a thread removes its candidates (threads are self-contained units)
+- `resolvedCandidateId` on threads: records which candidate won (for display in archived view)
+- `status` as text, not boolean: allows future states without migration (though only "active"/"resolved" for v1)
+- Candidate fields exactly mirror items fields: enables direct data copy on resolution
+
+### Pattern 2: Thread Resolution as Atomic Transaction
+
+**What:** Resolving a thread is a single transactional operation: create a collection item from the winning candidate's data, then set the thread status to "resolved" and record the winning candidate ID.
+
+**Why transaction:** If either step fails, neither should persist. A resolved thread without the corresponding item (or vice versa) would be an inconsistent state.
+
+```typescript
+// In thread.service.ts
+export function resolveThread(db: Db = prodDb, threadId: number, candidateId: number) {
+ return db.transaction(() => {
+ // 1. Get the candidate data
+ const candidate = db.select().from(threadCandidates)
+ .where(eq(threadCandidates.id, candidateId))
+ .get();
+ if (!candidate) return { success: false, error: "Candidate not found" };
+ if (candidate.threadId !== threadId) return { success: false, error: "Candidate not in thread" };
+
+ // 2. Check thread is active
+ const thread = db.select().from(threads)
+ .where(eq(threads.id, threadId))
+ .get();
+ if (!thread || thread.status !== "active") return { success: false, error: "Thread not active" };
+
+ // 3. Create collection item from candidate data
+ const newItem = db.insert(items).values({
+ name: candidate.name,
+ weightGrams: candidate.weightGrams,
+ priceCents: candidate.priceCents,
+ categoryId: candidate.categoryId,
+ notes: candidate.notes,
+ productUrl: candidate.productUrl,
+ imageFilename: candidate.imageFilename,
+ }).returning().get();
+
+ // 4. Archive the thread
+ db.update(threads).set({
+ status: "resolved",
+ resolvedCandidateId: candidateId,
+ updatedAt: new Date(),
+ }).where(eq(threads.id, threadId)).run();
+
+ return { success: true, item: newItem };
+ });
+}
+```
+
+### Pattern 3: Tab Navigation with TanStack Router
+
+**What:** The collection and planning views share the same page with tab switching. Use search params (`?tab=planning`) for tab state -- this keeps a single route file and avoids unnecessary nesting.
+
+**Why search params over nested routes:** Tabs are lightweight view switches, not distinct pages with their own data loading. Search params are simpler and keep the URL shareable.
+
+```typescript
+// In src/client/routes/index.tsx
+import { createFileRoute } from "@tanstack/react-router";
+import { z } from "zod";
+
+const searchSchema = z.object({
+ tab: z.enum(["gear", "planning"]).catch("gear"),
+});
+
+export const Route = createFileRoute("/")({
+ validateSearch: searchSchema,
+ component: HomePage,
+});
+
+function HomePage() {
+ const { tab } = Route.useSearch();
+ const navigate = Route.useNavigate();
+
+ return (
+
+
navigate({ search: { tab: t } })}
+ />
+ {tab === "gear" ? : }
+
+ );
+}
+```
+
+**Thread detail view:** When clicking a thread card, navigate to `/threads/$threadId` (a separate file-based route). This is a distinct page, not a tab -- it shows the thread's candidates.
+
+```
+src/client/routes/
+ index.tsx # Home with tabs (gear/planning)
+ threads/
+ $threadId.tsx # Thread detail: shows candidates
+```
+
+### Pattern 4: Reusing ItemForm for Candidates
+
+**What:** The candidate form shares the same fields as the item form. Rather than duplicating, adapt ItemForm to accept a `variant` prop or create a thin CandidateForm wrapper that uses the same field layout.
+
+**Recommended approach:** Create a `CandidateForm` that is structurally similar to `ItemForm` but posts to the candidate API endpoint. The form fields (name, weight, price, category, notes, productUrl, image) are identical.
+
+**Why not directly reuse ItemForm:** The form currently calls `useCreateItem`/`useUpdateItem` hooks internally and closes the panel via `useUIStore`. The candidate form needs different hooks and different store actions. A new component with the same field layout is cleaner than over-parameterizing ItemForm.
+
+### Anti-Patterns to Avoid
+
+- **Duplicating candidate data on resolution:** Copy candidate fields to a new item row. Do NOT try to "move" the candidate row or create a foreign key from items to candidates. The item should be independent once created.
+- **Deleting thread on resolution:** Archive (set status="resolved"), do not delete. Users need to see their decision history.
+- **Shared mutable state between tabs:** Each tab's data (items vs threads) should use separate TanStack Query keys. Tab switching should not trigger unnecessary refetches.
+- **Over-engineering the ConfirmDialog:** The existing ConfirmDialog is hardcoded to item deletion. For thread resolution, create a new `ResolveDialog` component (or make a generic ConfirmDialog). Do not try to make the existing ConfirmDialog handle both deletion and resolution through complex state.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Tab routing state | Manual useState for tabs | TanStack Router search params with `validateSearch` | URL-shareable, back-button works, type-safe |
+| Atomic resolution | Manual multi-step API calls | Drizzle `db.transaction()` | Guarantees consistency: either both item creation and thread archival succeed, or neither does |
+| Cache invalidation on resolution | Manual refetch calls | TanStack Query `invalidateQueries` for both `["items"]` and `["threads"]` keys | Ensures all views are fresh after resolution |
+| Price range display on thread cards | Custom min/max computation in component | SQL aggregate in the query (or compute from loaded candidates) | Keep computation close to data source |
+
+**Key insight:** Resolution is the only genuinely new pattern in this phase. Everything else (CRUD services, Hono routes, TanStack Query hooks, slide-out panels) is a direct replication of Phase 1 patterns with different table/entity names.
+
+## Common Pitfalls
+
+### Pitfall 1: Orphaned Candidate Images on Thread Delete
+**What goes wrong:** Deleting a thread cascades to delete candidates in the DB, but their uploaded images remain on disk.
+**Why it happens:** CASCADE handles DB cleanup but not filesystem cleanup.
+**How to avoid:** Before deleting a thread, query all its candidates, collect imageFilenames, delete the thread (cascade handles DB), then unlink image files. Wrap file cleanup in try/catch.
+**Warning signs:** Orphaned files in uploads/ directory.
+
+### Pitfall 2: Resolution Creates Item with Wrong Category
+**What goes wrong:** Candidate references a categoryId that was deleted between candidate creation and resolution.
+**Why it happens:** Category deletion reassigns items to Uncategorized (id=1) but does NOT reassign candidates.
+**How to avoid:** In the resolution transaction, verify the candidate's categoryId still exists. If not, fall back to categoryId=1 (Uncategorized). Alternatively, add the same FK constraint behavior to candidates.
+**Warning signs:** FK constraint violation on resolution INSERT.
+
+### Pitfall 3: Image File Sharing Between Candidate and Resolved Item
+**What goes wrong:** Resolution copies the candidate's `imageFilename` to the new item. If the thread is later deleted (cascade deletes candidates), the image cleanup logic might delete the file that the item still references.
+**How to avoid:** On resolution, copy the image file to a new filename (e.g., append a suffix or generate new UUID). The item gets its own independent copy. Alternatively, skip image deletion on thread/candidate delete if the filename is referenced by an item.
+**Warning signs:** Broken images on collection items that were created via thread resolution.
+
+### Pitfall 4: Stale Tab Data After Resolution
+**What goes wrong:** User resolves a thread on the Planning tab, then switches to My Gear tab and doesn't see the new item.
+**Why it happens:** Resolution mutation only invalidates `["threads"]` query key, not `["items"]` and `["totals"]`.
+**How to avoid:** Resolution mutation's `onSuccess` must invalidate ALL affected query keys: `["threads"]`, `["items"]`, `["totals"]`.
+**Warning signs:** New item only appears after manual page refresh.
+
+### Pitfall 5: Thread Detail Route Without Back Navigation
+**What goes wrong:** User navigates to `/threads/5` but has no obvious way to get back to the planning list.
+**Why it happens:** Thread detail is a separate route, and the tab bar is on the home page.
+**How to avoid:** Thread detail page should have a back link/button to `/?tab=planning`. The top navigation bar (per locked decision) should always be visible.
+**Warning signs:** User gets "stuck" on thread detail page.
+
+## Code Examples
+
+### Shared Zod Schemas for Threads and Candidates
+
+```typescript
+// Additions to src/shared/schemas.ts
+
+export const createThreadSchema = z.object({
+ name: z.string().min(1, "Thread name is required"),
+});
+
+export const updateThreadSchema = z.object({
+ name: z.string().min(1).optional(),
+});
+
+// Candidates share the same fields as items
+export const createCandidateSchema = z.object({
+ name: z.string().min(1, "Name is required"),
+ weightGrams: z.number().nonnegative().optional(),
+ priceCents: z.number().int().nonnegative().optional(),
+ categoryId: z.number().int().positive(),
+ notes: z.string().optional(),
+ productUrl: z.string().url().optional().or(z.literal("")),
+});
+
+export const updateCandidateSchema = createCandidateSchema.partial();
+
+export const resolveThreadSchema = z.object({
+ candidateId: z.number().int().positive(),
+});
+```
+
+### Thread Service Pattern (following item.service.ts)
+
+```typescript
+// src/server/services/thread.service.ts
+import { eq, desc, sql } from "drizzle-orm";
+import { threads, threadCandidates, items, categories } from "../../db/schema.ts";
+import { db as prodDb } from "../../db/index.ts";
+
+type Db = typeof prodDb;
+
+export function getAllThreads(db: Db = prodDb, includeResolved = false) {
+ const query = db
+ .select({
+ id: threads.id,
+ name: threads.name,
+ status: threads.status,
+ resolvedCandidateId: threads.resolvedCandidateId,
+ createdAt: threads.createdAt,
+ updatedAt: threads.updatedAt,
+ candidateCount: sql`(
+ SELECT COUNT(*) FROM thread_candidates
+ WHERE thread_id = ${threads.id}
+ )`,
+ minPriceCents: sql`(
+ SELECT MIN(price_cents) FROM thread_candidates
+ WHERE thread_id = ${threads.id}
+ )`,
+ maxPriceCents: sql`(
+ SELECT MAX(price_cents) FROM thread_candidates
+ WHERE thread_id = ${threads.id}
+ )`,
+ })
+ .from(threads)
+ .orderBy(desc(threads.createdAt));
+
+ if (!includeResolved) {
+ return query.where(eq(threads.status, "active")).all();
+ }
+ return query.all();
+}
+
+export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
+ const thread = db.select().from(threads)
+ .where(eq(threads.id, threadId)).get();
+ if (!thread) return null;
+
+ const candidates = db
+ .select({
+ id: threadCandidates.id,
+ threadId: threadCandidates.threadId,
+ name: threadCandidates.name,
+ weightGrams: threadCandidates.weightGrams,
+ priceCents: threadCandidates.priceCents,
+ categoryId: threadCandidates.categoryId,
+ notes: threadCandidates.notes,
+ productUrl: threadCandidates.productUrl,
+ imageFilename: threadCandidates.imageFilename,
+ createdAt: threadCandidates.createdAt,
+ updatedAt: threadCandidates.updatedAt,
+ categoryName: categories.name,
+ categoryEmoji: categories.emoji,
+ })
+ .from(threadCandidates)
+ .innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
+ .where(eq(threadCandidates.threadId, threadId))
+ .all();
+
+ return { ...thread, candidates };
+}
+```
+
+### TanStack Query Hooks for Threads
+
+```typescript
+// src/client/hooks/useThreads.ts
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
+
+export function useThreads(includeResolved = false) {
+ return useQuery({
+ queryKey: ["threads", { includeResolved }],
+ queryFn: () => apiGet(`/api/threads${includeResolved ? "?includeResolved=true" : ""}`),
+ });
+}
+
+export function useThread(threadId: number | null) {
+ return useQuery({
+ queryKey: ["threads", threadId],
+ queryFn: () => apiGet(`/api/threads/${threadId}`),
+ enabled: threadId != null,
+ });
+}
+
+export function useResolveThread() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ threadId, candidateId }: { threadId: number; candidateId: number }) =>
+ apiPost(`/api/threads/${threadId}/resolve`, { candidateId }),
+ onSuccess: () => {
+ // Invalidate ALL affected queries
+ queryClient.invalidateQueries({ queryKey: ["threads"] });
+ queryClient.invalidateQueries({ queryKey: ["items"] });
+ queryClient.invalidateQueries({ queryKey: ["totals"] });
+ },
+ });
+}
+```
+
+### Thread Routes Pattern (following items.ts)
+
+```typescript
+// src/server/routes/threads.ts
+import { Hono } from "hono";
+import { zValidator } from "@hono/zod-validator";
+import { createThreadSchema, updateThreadSchema, resolveThreadSchema,
+ createCandidateSchema, updateCandidateSchema } from "../../shared/schemas.ts";
+
+type Env = { Variables: { db?: any } };
+const app = new Hono();
+
+// Thread CRUD
+app.get("/", (c) => { /* getAllThreads */ });
+app.post("/", zValidator("json", createThreadSchema), (c) => { /* createThread */ });
+app.get("/:id", (c) => { /* getThreadWithCandidates */ });
+app.put("/:id", zValidator("json", updateThreadSchema), (c) => { /* updateThread */ });
+app.delete("/:id", (c) => { /* deleteThread with image cleanup */ });
+
+// Candidate CRUD (nested under thread)
+app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => { /* addCandidate */ });
+app.put("/:threadId/candidates/:candidateId", zValidator("json", updateCandidateSchema), (c) => { /* updateCandidate */ });
+app.delete("/:threadId/candidates/:candidateId", (c) => { /* removeCandidate */ });
+
+// Resolution
+app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => { /* resolveThread */ });
+
+export { app as threadRoutes };
+```
+
+### Test Helper Update
+
+```typescript
+// Addition to tests/helpers/db.ts createTestDb()
+
+sqlite.run(`
+ CREATE TABLE threads (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'active',
+ resolved_candidate_id INTEGER,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
+ )
+`);
+
+sqlite.run(`
+ CREATE TABLE thread_candidates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ weight_grams REAL,
+ price_cents INTEGER,
+ category_id INTEGER NOT NULL REFERENCES categories(id),
+ notes TEXT,
+ product_url TEXT,
+ image_filename TEXT,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
+ )
+`);
+```
+
+## State of the Art
+
+No new libraries or version changes since Phase 1. The entire stack is already installed and verified.
+
+| Phase 1 Pattern | Phase 2 Extension | Notes |
+|-----------------|-------------------|-------|
+| items table | threads + thread_candidates tables | Candidates mirror items schema |
+| item.service.ts | thread.service.ts | Same DI pattern, adds transaction for resolution |
+| /api/items routes | /api/threads routes | Nested candidate routes under thread |
+| useItems hooks | useThreads + useCandidates hooks | Same TanStack Query patterns |
+| ItemCard component | ThreadCard + CandidateCard | Same visual style with pill/chip tags |
+| ItemForm component | CandidateForm | Same fields, different API endpoints |
+| uiStore panel state | Extended with thread panel/dialog state | Same Zustand pattern |
+
+## Open Questions
+
+1. **Image handling on resolution**
+ - What we know: Candidate imageFilename is copied to the new item
+ - What's unclear: Should the file be duplicated on disk to prevent orphaned references?
+ - Recommendation: Copy the file to a new filename during resolution. This prevents the edge case where thread deletion removes an image still used by a collection item. The copy operation is cheap for small image files.
+
+2. **Thread deletion**
+ - What we know: Resolved threads are archived, not deleted. Active threads can be deleted.
+ - What's unclear: Should users be able to delete resolved/archived threads?
+ - Recommendation: Allow deletion of both active and archived threads with a confirmation dialog. Image cleanup required in both cases.
+
+3. **Category on thread cards**
+ - What we know: Thread cards show name, candidate count, date, price range
+ - What's unclear: Thread itself has no category -- it's a container for candidates
+ - Recommendation: Threads don't need a category. The pill tags on thread cards show: candidate count, date created, price range (min-max of candidates).
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Bun test runner (built-in, Jest-compatible API) |
+| Config file | None needed (Bun detects test files automatically) |
+| Quick run command | `bun test --bail` |
+| Full suite command | `bun test` |
+
+### Phase Requirements -> Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| THRD-01 | Create thread with name, list threads | unit | `bun test tests/services/thread.service.test.ts -t "create"` | No - Wave 0 |
+| THRD-01 | POST /api/threads validates input | integration | `bun test tests/routes/threads.test.ts -t "create"` | No - Wave 0 |
+| THRD-02 | Add candidate to thread with all fields | unit | `bun test tests/services/thread.service.test.ts -t "candidate"` | No - Wave 0 |
+| THRD-02 | POST /api/threads/:id/candidates validates | integration | `bun test tests/routes/threads.test.ts -t "candidate"` | No - Wave 0 |
+| THRD-03 | Update and delete candidates | unit | `bun test tests/services/thread.service.test.ts -t "update\|delete"` | No - Wave 0 |
+| THRD-04 | Resolve thread creates item and archives | unit | `bun test tests/services/thread.service.test.ts -t "resolve"` | No - Wave 0 |
+| THRD-04 | Resolve validates candidate belongs to thread | unit | `bun test tests/services/thread.service.test.ts -t "resolve"` | No - Wave 0 |
+| THRD-04 | POST /api/threads/:id/resolve end-to-end | integration | `bun test tests/routes/threads.test.ts -t "resolve"` | No - Wave 0 |
+| THRD-04 | Resolved thread excluded from active list | unit | `bun test tests/services/thread.service.test.ts -t "list"` | No - Wave 0 |
+
+### Sampling Rate
+- **Per task commit:** `bun test --bail`
+- **Per wave merge:** `bun test`
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+- [ ] `tests/services/thread.service.test.ts` -- covers THRD-01, THRD-02, THRD-03, THRD-04
+- [ ] `tests/routes/threads.test.ts` -- integration tests for thread API endpoints
+- [ ] `tests/helpers/db.ts` -- MODIFY: add threads + thread_candidates table creation
+
+## Sources
+
+### Primary (HIGH confidence)
+- Existing codebase: `src/db/schema.ts`, `src/server/services/item.service.ts`, `src/server/routes/items.ts` -- established patterns to replicate
+- Existing codebase: `tests/helpers/db.ts`, `tests/services/item.service.test.ts` -- test infrastructure and patterns
+- Existing codebase: `src/client/hooks/useItems.ts`, `src/client/stores/uiStore.ts` -- client-side patterns
+- Phase 1 research: `.planning/phases/01-foundation-and-collection/01-RESEARCH.md` -- stack decisions and verified versions
+- Drizzle ORM transactions: `db.transaction()` -- verified in category.service.ts (deleteCategory uses it)
+
+### Secondary (MEDIUM confidence)
+- TanStack Router `validateSearch` for search param validation -- documented in TanStack Router docs, used for tab routing
+
+### Tertiary (LOW confidence)
+- Image file copy on resolution -- needs implementation validation (best practice, but filesystem operations in Bun may have edge cases)
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH -- no new libraries, all from Phase 1
+- Architecture: HIGH -- direct extension of proven Phase 1 patterns, schema/service/route/hook layers
+- Pitfalls: HIGH -- drawn from analysis of resolution flow edge cases and Phase 1 experience
+- Database schema: HIGH -- mirrors items table (locked decision), transaction pattern established in category.service.ts
+
+**Research date:** 2026-03-15
+**Valid until:** 2026-04-15 (stable ecosystem, no fast-moving dependencies)