Hierarchical category taxonomy with versioned per-leaf spec schemas, structured products, uniform platform return policy, structured seller trust signals.
Taxonomy
The category tree is defined in code and seeded into Postgres.
seedTaxonomy() is idempotent and runs on test boot (test setup) and
on every deploy via the migrate container's CMD — there is no
server-boot seed.
Slugs are path-shaped (electronics/computers/laptops), unique
globally, and double as the public id surfaced over REST + MCP.
The seeded taxonomy is 343 leaves across 16 top-level categories
(broad-retail expansion 2026-07-07; Stationery & office added
2026-07-29). It mixes high-spec (laptops,
smartphones, washing-machines) and low-spec (memorabilia, sweets)
leaves so the system exercises both ends. The summary below lists roots
and subcategories only; the code definition in
packages/core/src/catalog/taxonomy.ts is authoritative.
home/ (43) appliances, furniture, kitchen*, bedding,
bathroom, lighting, decor, misc *knives 18+
electronics/ (49) phones, tablets-and-ereaders, wearables,
computers, gaming, tv-and-home-entertainment,
audio, cameras, smart-home, power
clothing/ (37) outerwear, tops, bottoms, dresses, activewear,
underwear-and-nightwear, footwear, accessories, kids
books-and-media/ (9) books, music, film-and-tv
sports/ (29) fitness, cycling, running, team-sports,
racket-sports, swimming, golf, snow, outdoor
beauty/ (18) skincare, cosmetics, haircare, fragrance,
mens-grooming
food-and-drink/ (11) beverages, snacks, pantry
pet/ (13) supplies, dogs, cats, small-pets
hobbies/ (16) art-supplies, board-games, musical-instruments,
crafts, collectibles
stationery/ (10) writing, paper-and-notebooks, office-supplies
toys-and-games/ (16) construction-toys, dolls-and-figures,
games-and-puzzles, outdoor-play, ride-ons,
educational-toys, remote-control
garden-and-diy/ (35) power-tools, hand-tools*, garden-power-tools,
garden-tools, garden-furniture, *knives-and-
plants-and-growing, decorating, blades 18+
hardware, storage-and-ladders,
workwear-and-safety
automotive/ (20) car-parts, oils-and-fluids, car-accessories,
car-care, tools-and-equipment, motorcycle
health-and-personal-care/(14) vitamins-and-supplements, personal-care,
health-devices, first-aid, mobility
baby/ (15) nursery, travel, feeding, changing, baby-clothing
jewellery-and-watches/ (8) jewellery, watches
The pre-expansion generic leaves (beauty/skincare/skincare,
food-and-drink/snacks/snacks, pet/supplies/*, …) are kept as
catch-alls alongside their new, more specific siblings — slugs are
never renamed or removed because live listings reference them, and the
seeder never updates existing rows.
The taxonomy is curator-only — sellers cannot nominate new leaves. New
leaves require a code change (taxonomy node + spec registration); the
next deploy's idempotent seed run inserts the category and spec rows.
Never add children under an existing leaf: the seeder would not update
its isLeaf flag.
Spec schemas
Per-leaf, in code, built with a typed DSL. The registry is split by
root category (packages/core/src/catalog/specs/<root>.ts, e.g.
home.ts, automotive.ts), merged and re-exported by
specs/index.ts (getSpec, ALL_LEAF_SLUGS). Taxonomy↔spec parity
is enforced both ways by unit tests.
spec({
cpu: str({ description: "CPU model" }),
ram_gb: num({ unit: "GB", min: 4, max: 256, filter: true, range: true }),
storage_kind: enumOf(["ssd", "hdd", "nvme"]),
has_thunderbolt: opt(bool()),
});
The DSL emits:
- A Zod schema used for server-side validation at listing time.
- A JSON Schema 2020-12 mirror stored in
category_specs.json_schemaand surfaced viaGET /api/v1/categories/:slugso agents can build correct queries and submissions. filterable+rangeablearrays driving the search filter shape.
Spec schemas are versioned. categories.current_version is the
canonical version for new listings; existing listings stay valid
against their stored category_version even after a schema bump.
Structured product record
The products row carries:
category_id(FK → categories) +category_versionspecs(jsonb, parsed against the leaf's Zod schema)images(text[], ordered)weight_grams,length_mm,width_mm,height_mmcountry_of_origin(ISO 3166-1 alpha-2)certifications(text[])restricted_category(re-derived from the keyword screen)return_policy(jsonb — legacy display shape, see below)improves_on_statutory(boolean; alwaysfalseunder the uniform returns policy — vestigial)search_vector(generated tsvector, weighted A/B/C)
Service surface:
searchProducts(ctx, input)— structured filters, paginated, trust-tier gated.getProduct(ctx, { product_id })— full structured detail.createProduct(ctx, input)— service-layer write used by the seller listing UI; validates spec, return policy, and runs the keyword screen.
Return policy
Platform-uniform — returns are no longer per-seller configurable (T74 uniform-returns work). Every listing carries the same guarantee, defined as constants in return-policy/validate.ts:
| Term | Value |
|---|---|
| Change-of-mind window | 30 days from delivery, free (RETURN_WINDOW_DAYS) |
| Restocking fee | None, ever (RESTOCKING_FEE_BPS = 0) |
| Return label | Seller-funded; prepaid label due within 2 business days of approval |
| Buyer ship-back window | 21 days from label issuance |
| Statutory cooling-off floor | 14 days (CCR 2013) — applies when buyer or seller is GB |
| Faulty goods | CRA 2015 30-day short-term reject on top (up to 6-year limitation) |
validateReturnPolicy is now a shim: any submitted policy is ignored
and every listing gets the uniform policy. Reads don't consult the stored
jsonb either — products/service.ts serves the uniform policy directly,
so no row can advertise a window or a restocking fee the returns engine
will never honour. Migration 0036 also realigned the column DEFAULT with
the uniform policy and backfilled the pre-uniform rows, so the stored
value is honest even though nothing reads it. The per-product
return_policy jsonb is retained only as a legacy display shape;
improvesOnStatutory is always false (every listing sits exactly on
the uniform floor), so the improves_on_statutory search filter is
vestigial. Authoritative behaviour lives in the returns service.
Restricted goods
Two independent enforcement paths:
- Listing-time keyword screen.
classifyByKeywordruns insidecreateProduct, settingrestricted_category/restricted_age_requiredon insert. The keyword list covers nine UK-specific categories. No buyer-time LLM classifier is wired into search; reads filter on these stored columns only — see therestrictedfilter under Search ranking. - Purchase-time delegation policy —
restricted_goods_policyaxis on the delegation, evaluated atcreatePurchaseIntent.
Both audit-log their decisions. Categories carry age_required
metadata (currently home/kitchen/knives and
garden-and-diy/hand-tools/knives-and-blades, both → 18) which feeds
the purchase-time policy.
Seller trust signals
Rolling 90-day window. Computed nightly at 03:00 UTC by the worker
(recomputeAllTrust). Idempotent — re-running the cron produces the
same numbers.
Tier thresholds:
| Tier | Conditions |
|---|---|
basic |
default — registered seller, Stripe Connect Express live |
verified |
≥ 50 orders / 90d, dispute rate ≤ 1.00%, on-time-ship ≥ 95.00% |
premium |
≥ 500 orders / 90d, dispute ≤ 0.50%, refund ≤ 5.00%, on-time-ship ≥ 98.00% (or admin-toggled) |
Public read at GET /api/v1/sellers/:id exposes
{ verification_tier, trust_metrics }. Metrics flagged stale: true
when computed_at is more than 36 hours old.
Search ranking
Postgres ilike on title/description. See search.md.
Filters: category (exact or subtree), spec equality, spec ranges
(spec_min / spec_max), certifications, min_seller_tier,
improves_on_statutory, restricted (allow | exclude_age |
exclude_all), seller id.
Listing UI
Server-rendered at /sellers/<store>/listings/new. Two-step:
- Pick a leaf category from the tree.
- Fill structured base fields + auto-generated spec form (driven by the leaf's spec metadata: number-with-unit, enum select, bool checkbox, free text).
Submitting calls createProduct which validates spec + return policy
- keyword screen in one transaction.