---
phase: 19-reference-item-model-tags-schema
verified: 2026-04-06T00:00:00Z
status: passed
score: 15/15 must-haves verified
re_verification: false
---

# Phase 19: Reference Item Model & Tags Schema — Verification Report

**Phase Goal:** Collection items can be references to global catalog entries, and global items support tags for discovery
**Verified:** 2026-04-06
**Status:** passed
**Re-verification:** No — initial verification

---

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | `items` table has `globalItemId` nullable FK and `purchasePriceCents` nullable integer | VERIFIED | `src/db/schema.ts` lines 58-59 |
| 2 | `threadCandidates` table has `globalItemId` nullable FK | VERIFIED | `src/db/schema.ts` line 102 |
| 3 | `tags` table exists with id, name (unique), createdAt | VERIFIED | `src/db/schema.ts` lines 149-155 |
| 4 | `globalItemTags` join table exists with composite PK | VERIFIED | `src/db/schema.ts` lines 157-167 |
| 5 | `itemGlobalLinks` table removed from schema | VERIFIED | No match in schema.ts, types.ts, schemas.ts |
| 6 | Migration includes data migration step before table drop | VERIFIED | `drizzle-pg/0002_wakeful_vermin.sql` lines 17-22: UPDATE before DROP |
| 7 | `getAllItems`/`getItemById` return merged data via COALESCE for reference items | VERIFIED | `item.service.ts` lines 12-45: LEFT JOIN + COALESCE for name, weight, price, image |
| 8 | `createItem` accepts globalItemId and purchasePriceCents | VERIFIED | `item.service.ts` lines 99-123; `createItemSchema` includes both fields |
| 9 | `duplicateItem` preserves globalItemId | VERIFIED | `item.service.ts` line 186 |
| 10 | Thread candidates support globalItemId with merged global item display | VERIFIED | `thread.service.ts` lines 84-117: COALESCE merge + leftJoin in getThreadWithCandidates |
| 11 | `resolveThread` branches on candidate.globalItemId (reference vs standalone) | VERIFIED | `thread.service.ts` line 356: `if (candidate.globalItemId)` branching |
| 12 | Link/unlink endpoints removed from items route | VERIFIED | `src/server/routes/items.ts`: no linkItemToGlobal, unlinkItemFromGlobal, or /:id/link |
| 13 | Global item search supports tag filtering with AND logic | VERIFIED | `global-item.service.ts` lines 32-44: subquery with `HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length}` |
| 14 | All secondary services (setup, totals, profile, CSV) merge global item data | VERIFIED | All 4 services import globalItems and use LEFT JOIN + COALESCE |
| 15 | Seed script creates 28+ curated tags | VERIFIED | `seed-global-items.ts`: SEED_TAGS array with 30 entries; `seedTags` async function |

**Score:** 15/15 truths verified

---

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/db/schema.ts` | globalItemId on items + candidates, tags, globalItemTags, no itemGlobalLinks | VERIFIED | Contains "globalItemId" 8+ times; tags and globalItemTags tables present; itemGlobalLinks absent |
| `src/shared/schemas.ts` | createItemSchema + createCandidateSchema with globalItemId; searchGlobalItemsSchema with tags; no linkItemSchema | VERIFIED | Lines 13-14 (item), line 63 (candidate), line 101 (search); no linkItemSchema |
| `src/shared/types.ts` | Tag and GlobalItemTag types; no ItemGlobalLink or LinkItem | VERIFIED | Lines 62-63 export Tag and GlobalItemTag; no ItemGlobalLink anywhere |
| `tests/helpers/db.ts` | Compatible with new schema via PGlite migrations | VERIFIED | Uses `migrate(db, { migrationsFolder: "./drizzle-pg" })` — picks up new migration automatically |
| `src/db/seed-global-items.ts` | SEED_TAGS array (25+), async seedTags, async seedGlobalItems | VERIFIED | 30 tags, both functions async |
| `src/server/services/item.service.ts` | COALESCE merge in getAllItems + getItemById; createItem + duplicateItem support globalItemId | VERIFIED | All 4 functions updated with correct patterns |
| `src/server/services/thread.service.ts` | createCandidate stores globalItemId; getThreadWithCandidates merges; resolveThread branches | VERIFIED | All 3 behaviors present |
| `src/server/routes/items.ts` | No link/unlink routes; passes globalItemId through via createItemSchema | VERIFIED | Clean — only 7 routes remain; createItemSchema includes new fields |
| `tests/services/item.service.test.ts` | Tests for reference item creation and merged data retrieval | VERIFIED | Describe block "reference items (globalItemId)" with 8+ test cases |
| `tests/services/thread.service.test.ts` | Tests for catalog-linked candidate resolution | VERIFIED | Describe block "catalog-linked candidates (globalItemId)" with 5+ test cases |
| `src/server/services/global-item.service.ts` | Tag-filtered search; owner count via items.globalItemId; no linkItemToGlobal | VERIFIED | tagNames param, ilike, HAVING COUNT(DISTINCT), items.globalItemId FK query |
| `src/server/services/setup.service.ts` | COALESCE merge in getAllSetups totals + getSetupWithItems | VERIFIED | LEFT JOIN global_items in subqueries; leftJoin(globalItems) in getSetupWithItems |
| `src/server/services/totals.service.ts` | COALESCE merge in getCategoryTotals + getGlobalTotals | VERIFIED | Both functions have leftJoin(globalItems) and COALESCE SUM |
| `src/server/services/profile.service.ts` | COALESCE merge in getPublicProfile + getPublicSetupWithItems | VERIFIED | LEFT JOIN global_items in raw SQL subqueries; leftJoin(globalItems) in item list query |
| `src/server/services/csv.service.ts` | COALESCE merge in exportItemsCsv | VERIFIED | leftJoin(globalItems) with COALESCE for name, weight, price |
| `src/server/routes/global-items.ts` | tags query param parsing; async handlers | VERIFIED | Lines 15-22: tagsParam split and forwarded; both handlers async with await |
| `tests/services/global-item.service.test.ts` | Async PGlite tests for tag filtering and owner count | VERIFIED | Uses await pattern; tests for single tag, AND logic, combined search, ownerCount |
| `drizzle-pg/0002_wakeful_vermin.sql` | Migration with ADD COLUMN, data migration UPDATE, DROP TABLE | VERIFIED | UPDATE before DROP confirmed |

---

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `src/db/schema.ts` | Drizzle PG migration | `bun run db:generate` | VERIFIED | `drizzle-pg/0002_wakeful_vermin.sql` contains `global_item_id` ADD COLUMN |
| `src/shared/schemas.ts` | `src/shared/types.ts` | Zod inference | VERIFIED | `globalItemId` in schema; types.ts imports `tags`, `globalItemTags` from schema |
| `src/server/services/item.service.ts` | `src/db/schema.ts` (globalItems) | LEFT JOIN + COALESCE | VERIFIED | `leftJoin(globalItems, eq(items.globalItemId, globalItems.id))` in getAllItems and getItemById |
| `src/server/services/thread.service.ts` | `src/db/schema.ts` (items) | conditional insert on candidate.globalItemId | VERIFIED | Line 356: `if (candidate.globalItemId)` sets `globalItemId: candidate.globalItemId` in insert |
| `src/server/services/global-item.service.ts` | `src/db/schema.ts` (tags, globalItemTags) | subquery with GROUP BY HAVING | VERIFIED | Lines 32-44: subquery joins globalItemTags + tags with HAVING COUNT(DISTINCT) |
| `src/server/routes/global-items.ts` | `src/server/services/global-item.service.ts` | tag param parsing and forwarding | VERIFIED | `tagsParam.split(",").map(t => t.trim()).filter(Boolean)` forwarded as tagNames |
| `src/server/services/setup.service.ts` | `src/db/schema.ts` (globalItems) | LEFT JOIN in subqueries | VERIFIED | Raw SQL subqueries contain `LEFT JOIN global_items ON global_items.id = items.global_item_id` |

---

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `getAllItems` | `name` (merged) | COALESCE over globalItems.brand + globalItems.model | Yes — leftJoin on globalItems FK | FLOWING |
| `getItemById` | `weightGrams` (merged) | COALESCE over globalItems.weightGrams | Yes — leftJoin on globalItems FK | FLOWING |
| `getThreadWithCandidates` | candidate `name` (merged) | COALESCE over globalItems.brand + globalItems.model | Yes — leftJoin on globalItems FK | FLOWING |
| `resolveThread` | new item `globalItemId` | candidate.globalItemId from DB row | Yes — reads candidate from DB in transaction | FLOWING |
| `searchGlobalItems` | results filtered by tag | subquery JOIN globalItemTags+tags with HAVING | Yes — queries tags and globalItemTags tables | FLOWING |
| `getGlobalItemWithOwnerCount` | ownerCount | `count()` from items WHERE globalItemId=id | Yes — direct FK count on items table | FLOWING |
| `getAllSetups` totalWeight | COALESCE SUM | raw SQL subquery with LEFT JOIN global_items | Yes — aggregates over real item rows | FLOWING |
| `exportItemsCsv` name | COALESCE name | leftJoin(globalItems) + CASE WHEN | Yes — joins real globalItems table | FLOWING |

---

### Behavioral Spot-Checks

Step 7b: SKIPPED — server requires running PGlite/Postgres instance. Tests are the verified behavioral contracts (noted as all passing in prompt context: 13 + 24 + 40 = 77 tests passing).

---

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| CATFLOW-03 | 19-01, 19-02 | User can add catalog item to collection as reference item with personal fields | PARTIAL (server complete, client deferred to Phase 21) | `createItem` accepts `globalItemId`+`purchasePriceCents`; API route wired; no client UI in Phase 19 scope |
| CATFLOW-04 | 19-01, 19-02, 19-03 | Collection items referencing global items display merged data | SATISFIED | COALESCE merge in all 7 query surfaces (item, thread, setup, totals, profile, CSV, global-item) |
| CATFLOW-05 | 19-02 | Thread candidates can be added from catalog with global item link | PARTIAL (server complete, client deferred to Phase 21) | `createCandidate` accepts `globalItemId`; `getThreadWithCandidates` merges global data; no client UI in Phase 19 scope |
| CATFLOW-06 | 19-02 | Thread resolution with catalog-linked candidate creates reference item | PARTIAL (server complete, client deferred to Phase 21) | `resolveThread` branches on `candidate.globalItemId` — creates reference item when set; no client flow in Phase 19 scope |
| TAG-01 | 19-01 | Tags table seeded with curated tag set | SATISFIED | `seed-global-items.ts` seeds 30 tags covering bikepacking/outdoor/camping gear |
| TAG-02 | 19-01, 19-03 | Global items have multiple tags, searchable and filterable via API | SATISFIED | `globalItemTags` join table; `GET /api/global-items?tags=x,y` with AND logic via HAVING COUNT(DISTINCT) |

**Note on PARTIAL requirements:** CATFLOW-03, -05, -06 are listed as "Phase 19, 21" in REQUIREMENTS.md. Phase 19 delivers the server-side foundation (schema, service layer, API routes). The client-side catalog UI and user-facing flows are deferred to Phase 21. This split is intentional and documented in REQUIREMENTS.md. No gaps exist in what Phase 19 was responsible for delivering.

---

### Anti-Patterns Found

No blocking anti-patterns detected. Scan of all 7 modified service files and 2 modified route files returned no TODO/FIXME/placeholder comments, no empty implementations, no hardcoded empty arrays as final return values, and no stub patterns. The COALESCE pattern is consistently applied across all query surfaces.

| File | Pattern | Severity | Impact |
|------|---------|----------|--------|
| — | — | — | None found |

---

### Human Verification Required

The following behaviors are correct by code inspection but cannot be verified programmatically without a running server:

#### 1. Reference Item Display in UI

**Test:** Create a reference item via `POST /api/items` with `globalItemId` pointing to an existing global item (no name/weight/price on the item row). Fetch via `GET /api/items`.
**Expected:** Response contains global item's brand+model as `name`, global item's `weightGrams` and `priceCents`, item's own `globalItemId` field.
**Why human:** Requires live server + seeded global items.

#### 2. Tag Filtering via API

**Test:** Call `GET /api/global-items?tags=ultralight,bikepacking` after seeding global items with those tags.
**Expected:** Returns only global items tagged with BOTH "ultralight" AND "bikepacking" (AND intersection, not OR union).
**Why human:** Requires live server + seeded data.

#### 3. Thread Resolution Creates Reference Item

**Test:** Add a candidate to a thread with a valid `globalItemId`. Resolve the thread with that candidate. Inspect the created collection item.
**Expected:** New item has `globalItemId` set, `weightGrams` and `priceCents` null on the item row, but `GET /api/items` returns merged global data.
**Why human:** Requires live server + full thread flow.

---

### Gaps Summary

No gaps. All 15 observable truths are verified. All artifacts exist, are substantive (not stubs), are wired to their data sources, and have real data flowing through all query paths. The PARTIAL status of CATFLOW-03, -05, -06 is not a gap — it reflects intentional phase scope (server foundation in Phase 19, client UI in Phase 21), as documented in REQUIREMENTS.md.

The phase goal is fully achieved: **collection items can be references to global catalog entries (server layer), and global items support tags for discovery** (schema + API + seed data).

---

_Verified: 2026-04-06_
_Verifier: Claude (gsd-verifier)_
