Appearance
Sealed products: adopt MTGJSON's official category taxonomy
Date: 2026-08-06 Status: Approved, not yet implemented Games: MTG (primary); Pokemon and Riftbound affected by the per-game gate (section 7)
Problem
Sealed product categories are derived by categorizeProduct(name) in server/utils/sealedProductUtils.js — a 25-line chain of substring tests over the product name. MTGJSON ships an official category on every sealed product and a subtype on 95% of them, and we ignore both.
Measured against the local mtg_sets catalog (340 sets, 4,123 sealed products):
| Measure | Value |
|---|---|
MTGJSON category coverage | 4,123 / 4,123 (100%) |
MTGJSON subtype coverage | 3,927 / 4,123 (95%) |
Products the name regex puts in other | 2,069 (50.2%) |
| Products MSRP-mappable under the regex | 2,031 / 4,123 (49.3%) |
Distinct real (category, subtype) pairs | 140 |
Three concrete defects follow:
- Booster cases are labelled booster boxes. 229
booster_caseproducts resolve tobooster_boxand 51 tocollector_booster_box. Because MSRP is an absolute dollar fallback (priceLookupService.js,price: msrp, source: 'msrp'), a booster case with no TCGCSV market price is priced on the storefront at a single box's MSRP — roughly a sixfold underprice. This is the acute money bug. - Half of what a merchant imports is labelled "Other". 1,003
box_setand 621deckproducts land there, including 741 Secret Lairs and 209 Secret Lair bundles. That value is written to thesealed_typemetafield, shown in the merchant's sealed products list, and used for its category filter — so merchants and shoppers both see it. It does not affect catalog browse, which reads MTGJSON's category directly. - MTG's taxonomy is applied to Pokemon. Pokemon and Riftbound declare
hasSealedCategoryTaxonomy: false, yet theirtransformSealedToProductcallscategorizeProductanyway. A Pokemon Sleeved Booster Pack is labelled "Draft Booster", a category Pokemon does not have; an Elite Trainer Box — the single most common Pokemon sealed SKU — is labelled "Other".
Note on severity, because an earlier reading of this overstated it: buylist categoryRates are percentages applied to each product's own market price (buylistQuoteService.js, applySealedBuylistRate). A case labelled as a box therefore gets the box percentage applied to the case's own price. That is a lost capability — merchants cannot rate cases differently from boxes — not a sixfold quoting error. The sixfold error is MSRP only.
Goals
- Correct buylist rates: merchants can rate booster cases separately from boxes.
- Correct MSRP resolution: stop cases resolving a box MSRP, and accept a lower mappable-coverage number as the price of that correctness. Measured over the real catalog after implementation, MSRP-mappable coverage went down, from 49.7% to 43.5% — the opposite of what an earlier draft of this goal predicted ("well above 49.3%"), and the earlier wording is withdrawn rather than met. The drop is desirable: nearly all of it is products that used to inherit a wrong MSRP and now correctly resolve none — Secret Lair bundles, bundle cases, and commander-deck displays no longer take the MSRP of a plain box set, bundle, or single deck. A product with no MSRP falls back to its own market price; a product with someone else's MSRP is mispriced on the storefront. Coverage is therefore the wrong headline metric here; the metric that moved the right way is products landing in
other, 50.2% → 0.5%. Restoring coverage is a seeding exercise (section 3 and the open questions below), not a taxonomy change. - Accurate merchant-facing labels in the
sealed_typemetafield and catalog UI. - Make the imported-products list agree with catalog browse. These already disagree: a merchant browses a product labelled "Box Set", imports it, and it appears in their products list as "Other" (see below).
Non-goals
- A Pokemon-native sealed vocabulary (Elite Trainer Box, Booster Bundle, Tin, Build and Battle). Deferred to its own spec; see section 7.
- Exposing MTGJSON
subtypeas a separate Shopify metafield. Not required by any goal above; adding it now would be unwired scaffolding (CLAUDE.md section 5.9). - Changing how sealed prices are fetched, cached, or rounded.
- Sealed product weight. It has no upstream source in either MTGJSON or TCGCSV and needs a seeded reference table; tracked separately.
Where the vocabulary lives today
Two different vocabularies are already live in production, and this is the single most important fact for anyone implementing this:
| Surface | Vocabulary | Source |
|---|---|---|
GET /catalog/sealed (browse) | raw MTGJSON — box_set, booster_case, subset, unknown | routes/catalog.js projects category: '$data.sealedProduct.category' and filters on data.sealedProduct.category; catalogSealedQuerySchema.category is a free z.string().max(80), so it never validated against our vocabulary |
GET /sealed-products (imported) | the 17-key regex vocabulary | categorizeProduct(name) at import time, validated by sealedProductsListQuerySchema.category against SEALED_CATEGORIES |
So the "Box Set" a merchant sees while browsing becomes "Other" once imported. Catalog browse was never affected by the 50%-other defect, because it never used the regex.
That has a useful consequence: raw MTGJSON categories are already proven end-to-end through browse, filter and UI label. This design extends that vocabulary to the import path rather than introducing an unproven one.
The definition itself is spread across four places (CLAUDE.md section 5.8):
| Location | Form |
|---|---|
server/utils/sealedProductUtils.js | CATEGORY_LABELS_SERVER, SEALED_CATEGORIES, CATEGORY_TO_MSRP_TYPE — 17 keys, the de facto source of truth for the import path |
server/models/SealedProduct.js | the category enum — 21 values, a superset; set_booster, planeswalker_deck, from_the_vault, secret_lair are never emitted |
server/schemas/queries.js | a hardcoded 17-value SEALED_CATEGORIES for the /sealed-products filter |
client/src/pages/catalog/CatalogSealedPage.jsx | CATEGORY_LABELS — not a duplicate: a merged map already carrying keys from both vocabularies, which is why it contains booster_case and box_set alongside draft_booster and scene_box |
All four must end up derived from one definition.
A prior decision this design overturns
scripts/data-loading/updateSealedProductPrices.js carries a comment explaining why the raw category was rejected before:
a Collector Booster Box is
category: 'booster_box', subtype: 'collector'. So the name — not the raw category — is what carries the distinction
That reasoning is correct about category alone and is exactly why this design keys on the (category, subtype) pair, which does carry the distinction. The comment must be updated in the same PR, or the next reader will revert the change on its authority.
Three stored-data surfaces are affected: SealedProduct.category documents, Store.buylistConfig[game].sealed.categoryRates (merchant-configured payout rates), and BuylistOrder.category on historical orders. The sealed_type metafield is written to Shopify but drives no smart-collection rule, so there is no collection breakage.
Design
1. Vocabulary
A single flat vocabulary of 25 keys serves rates, MSRP mapping, labels and filters. The fold rule: split where MSRP or a merchant rate would plausibly differ; fold where only the era-name differs. Every key is derived from real (category, subtype) pairs observed in the catalog — no invented literals (CLAUDE.md section 5.4).
The table is matched most-specific first: an exact category|subtype entry wins over a category wildcard. This matters in three places where both exist — deck_box resolves to two_player_starter for that subtype and deck_display otherwise; subset resolves to commander_deck for that subtype and multiple_decks otherwise; box_set resolves to secret_lair or beginner_box before falling through to box_set.
| Key | MTGJSON category | MTGJSON subtypes |
|---|---|---|
booster_pack | booster_pack | draft, play, set, default, six-card, promotional, welcome, advanced, premium, topper, other |
collector_booster | booster_pack | collector |
theme_booster | booster_pack | theme |
jumpstart_booster | booster_pack | jumpstart |
booster_box | booster_box | draft, play, set, default, premium, theme, jumpstart |
collector_booster_box | booster_box | collector |
booster_case | booster_case | all except collector |
collector_booster_case | booster_case | collector — added 2026-08-09, closing gate 2 |
bundle | bundle | default, fat_pack, premium |
gift_bundle | bundle | gift_bundle |
bundle_case | bundle_case | all |
commander_deck | deck, subset | commander |
planeswalker_deck | deck | planeswalker |
deck | deck | theme, intro, starter_deck, duel, challenger, event, championship, welcome, planechase |
deck_display | deck_box | all |
multiple_decks | multiple_decks, subset | all others |
two_player_starter | multiple_decks, deck_box | two_player_starter |
prerelease_pack | limited_aid_tool, booster_pack, subset | prerelease_kit |
limited_aid_tool | limited_aid_tool | draft_set, tournament_deck, starter_deck |
limited_aid_case | limited_aid_case | all |
secret_lair | box_set | secret_lair, secret_lair_bundle |
beginner_box | box_set | starter_deck |
box_set | box_set | all others, including absent subtype |
kit | kit | deck_builders_toolkit, guild_kit, land_station |
other | unknown | all; plus any unmapped pair |
booster_case and secret_lair are new. secret_lair alone reclassifies 950 products currently labelled "Other".
Two keys are renamed or retired: today's draft_booster becomes booster_pack (it was always the generic bucket), and scene_box stops being emitted. pioneer_deck was already never emitted and is retired from the enum.
2. Derivation
One table in sealedProductUtils.js keyed on the string "category|subtype", with a per-category fallback for pairs whose subtype is absent or unlisted. One exported function replaces the name regex:
js
deriveSealedCategory(plugin, product) -> string | null- Returns
nullwhenplugin.hasSealedCategoryTaxonomyis false. Gating inside this one function is what resolves the Pokemon contradiction — one gate rather than six scattered checks at call sites. - Returns
otherfor an MTG product whose pair is unmapped, and logs the unmapped pair at warn level. MTGJSON adds subtypes over time; a new one must be visible in importer output rather than silently absorbed.
categorizeProduct(name) is deleted, not kept as a fallback. All six call sites already hold the MTGJSON document rather than just the name — verified: the MTG plugin transform, routes/sealedProducts.js (import price inputs and preview), routes/catalog.js, scripts/data-loading/updateSealedProductPrices.js, and services/buylistQuoteService.js via its injected dependency. Since category is present on 100% of products, a name-based fallback would never fire.
SEALED_CATEGORIES in server/schemas/queries.js is replaced by an import from the single definition, which also widens the /sealed-products category filter to accept the new keys.
CATEGORY_LABELS in CatalogSealedPage.jsx needs care rather than deletion: it currently serves both vocabularies, and catalog browse still returns raw MTGJSON categories after this change. Since the new 25-key vocabulary is a superset-shaped projection of the raw categories, the merged map converges — but the browse-only keys (subset, unknown, deck_box) must be kept until browse is migrated too. Deleting them would regress the browse page's labels. Browse alignment is explicitly out of scope for PR1 and tracked as a follow-up.
3. MSRP mapping
CATEGORY_TO_MSRP_TYPE gains entries for the new keys, most importantly a case type so a booster case no longer resolves play_booster_box. Adding a play_booster_case (and collector_booster_case) row to SealedProductMSRP.productType is required; those MSRP values come from the seed script and are the data Brent supplies.
Two invariants, both enforced by tests:
- Every value in
CATEGORY_TO_MSRP_TYPEis a validSealedProductMSRP.productType. This drift test already exists and must keep passing. - Every
productTypeis either reachable from a category key or on an explicit retired list. This is new, and is the guard against the fold silently reducing MSRP coverage while the change is nominally about raising it.
The retired list is required rather than cosmetic — the strict form of invariant 2 fails on main today, before any change here:
productType | Status | Reason |
|---|---|---|
specialty_bundle | Retired | Already unreachable on main; no key has ever mapped to it |
pioneer_deck | Retired | Its key was never emitted by the name regex, and MTGJSON has no pioneer subtype |
starter_collection | Retired | Its products carry box_set in MTGJSON and fold into the box_set key |
The test asserts reachable ∪ retired == all productTypes, so a future fold that strands a live type fails the build, and each retirement is a deliberate line in a list rather than an accident. beginner_box, deck_builder_toolkit, two_player_starter and planeswalker_deck all keep dedicated keys precisely so they stay reachable.
4. Merchant rate migration
New keys mean products move out of buckets merchants may already have rates on. The seeding source is computed from the catalog, not guessed: for each new key, the migration determines which old key the plurality of that key's products previously resolved to, and seeds the merchant's rate from it. Expected outcomes:
booster_packfromdraft_booster(a rename, so the value carries directly)booster_casefrombooster_boxbundle_casefrombundlesecret_lair,box_set,deck,deck_display,multiple_decks,limited_aid_toolfromotherkitfromdeck_builder_toolkitlimited_aid_casefromprerelease_pack
Properties: idempotent, dry-run mode, and it only writes a key that is not already set, so a merchant who has already configured a new key is never overwritten. Quoted offers do not move on deploy.
Orphaned keys (draft_booster, scene_box) are removed in the same pass. Without that they would be silently stripped by the buylistConfig Zod object on the merchant's next settings save, which reads as unexplained data loss.
5. Backfill
Phase 1, database. A migration re-derives SealedProduct.category for every existing document by joining uuid to mtg_sets, and recomputes the stored msrp through the corrected mapping. Dry-run mode reports the change counts per category before anything is written.
Phase 2, Shopify. A per-store queued job pushes the corrected sealed_type and msrp metafields to live products, scoped to documents whose derived category actually changed and which have a shopifyProductId. It goes through services/shopifyAPI.js so the cost-aware rate limiter applies (CLAUDE.md rule 6), and is resumable and dry-runnable. sealed_type is an app-managed metafield, so overwriting a merchant edit is acceptable and intended.
6. Schema changes
SealedProduct.categorybecomes optional and its enum is updated to the 25 keys. It isrequired: truetoday, so Pokemon and Riftbound returningnullis not currently representable. This is the least reversible change in the spec: it alters a live, per-store collection.- A migration nulls the regex-derived categories already stored on Pokemon and Riftbound documents.
- Per CLAUDE.md section 5.2, both ship with schema round-trip tests — write through the model, read back, assert the field survives — following
server/models/ShopifyPokemonProductVariant.test.js.
7. Per-game handling
Required by CLAUDE.md section 5.1. Every registered plugin is named:
| Game | Treatment |
|---|---|
| mtg | Full taxonomy. hasSealedCategoryTaxonomy: true, derives from the pair table. |
| pokemon | Stops receiving MTG's taxonomy. hasSealedCategoryTaxonomy stays false, deriveSealedCategory returns null, sealed_type is omitted rather than wrong, and no category rate rows are offered. A Pokemon-native vocabulary is a follow-up spec. |
| riftbound | Identical to Pokemon, and for the same reason. |
The interim cost is explicit: Pokemon products lose the occasionally-correct "Bundle" and "Booster Box" labels they get today by accident. That is preferred over continuing to label a Pokemon pack a "Draft Booster".
8. Testing
- Table-driven derivation tests over representative pairs drawn from all 140 real combinations, including every pair with an absent subtype.
- A completeness test asserting no real catalog pair falls to
otherunintentionally. - The MSRP drift test in both directions (section 3).
- Schema round-trip tests for the
categorychanges (section 6). - Migration tests covering dry-run, idempotency, the not-already-set guard, and orphan removal.
- Per-game tests asserting
deriveSealedCategoryreturnsnullfor pokemon and riftbound, and that their transforms omitsealed_type.
Definition of done is CLAUDE.md section 7 in full: npm test, npm run test:client (the catalog page changes), npm run lint, npm run build, and npm run docs:build if docs change.
Rollout
Three PRs, per the small-PR convention in CLAUDE.md section 3. Each is independently shippable and revertible.
| PR | Contents | Merchant-visible effect |
|---|---|---|
| 1 | Vocabulary, derivation table, per-game gate, MSRP map, schema changes, all four vocabulary copies unified | New imports get correct categories; Pokemon stops getting MTG's |
| 2 | Merchant rate migration | A new booster_case row appears, pre-seeded; quotes unchanged |
| 3 | Backfill phases 1 and 2 | Existing products stop saying "Other"; case MSRP corrected |
Open questions
Gates that must be settled BEFORE any play_booster_case MSRP row is seeded
Both were discovered by measuring the implemented branch against the real catalog. Neither is fixed by PR1, and seeding case MSRPs while either is open would ship the wrong price.
play_booster_casehas zero MSRP rows. The headline money fix (a booster case no longer resolving a box MSRP) currently produces "no MSRP" rather than "correct MSRP":play_booster_caseis a validSealedProductMSRP.productType, but the collection has no rows for it andscripts/data-loading/seedSealedProductMSRP.jsseeds none. Until Brent supplies case MSRPs, the fix is a correction (stop being wrong), not a completion (start being right).Collector cases would price at the play-case MSRP.RESOLVED 2026-08-09 —collector_booster_casewas added as a 25th vocabulary key. The pairbooster_case|collector(49 real products) now resolves tocollector_booster_caseand maps to its ownSealedProductMSRP.productTypeof the same name, so collector cases can never inherit the play-case value. The vocabulary is 25 keys, not 24; section 1's table records the addition.
Gate order: gate (2) is closed, so seeding is blocked only on gate (1). Both a play_booster_case and a collector_booster_case MSRP value are now needed — seeding only the former would leave the 49 collector-case products resolving no MSRP, which is safe (they fall back to market price) but incomplete.
Decisions recorded so they are not silently revisited
Two decisions were taken deliberately:
- Flat vocabulary rather than split rate-bucket and label fields. A two-field design (small rate buckets, high-fidelity labels) was considered and rejected as the starting point in favour of one field. If the 25-row rate table proves unwieldy in practice, coarsening the rate side later is a fold over the same table, not a rewrite.
- Nine minor deck subtypes folded into
deck.theme(206 products) andintro(160) are the largest and could be split out later without disturbing the others.
