# 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>

## 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)

</user_constraints>

<phase_requirements>

## 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 |

</phase_requirements>

## 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 (
    <div>
      <TabSwitcher
        active={tab}
        onChange={(t) => navigate({ search: { tab: t } })}
      />
      {tab === "gear" ? <CollectionView /> : <PlanningView />}
    </div>
  );
}
```

**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<number>`(
        SELECT COUNT(*) FROM thread_candidates
        WHERE thread_id = ${threads.id}
      )`,
      minPriceCents: sql<number | null>`(
        SELECT MIN(price_cents) FROM thread_candidates
        WHERE thread_id = ${threads.id}
      )`,
      maxPriceCents: sql<number | null>`(
        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<Env>();

// 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)
