Skip to content

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

MeasureValue
MTGJSON category coverage4,123 / 4,123 (100%)
MTGJSON subtype coverage3,927 / 4,123 (95%)
Products the name regex puts in other2,069 (50.2%)
Products MSRP-mappable under the regex2,031 / 4,123 (49.3%)
Distinct real (category, subtype) pairs140

Three concrete defects follow:

  1. Booster cases are labelled booster boxes. 229 booster_case products resolve to booster_box and 51 to collector_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.
  2. Half of what a merchant imports is labelled "Other". 1,003 box_set and 621 deck products land there, including 741 Secret Lairs and 209 Secret Lair bundles. That value is written to the sealed_type metafield, 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.
  3. MTG's taxonomy is applied to Pokemon. Pokemon and Riftbound declare hasSealedCategoryTaxonomy: false, yet their transformSealedToProduct calls categorizeProduct anyway. 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

  1. Correct buylist rates: merchants can rate booster cases separately from boxes.
  2. 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.
  3. Accurate merchant-facing labels in the sealed_type metafield and catalog UI.
  4. 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 subtype as 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:

SurfaceVocabularySource
GET /catalog/sealed (browse)raw MTGJSONbox_set, booster_case, subset, unknownroutes/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 vocabularycategorizeProduct(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):

LocationForm
server/utils/sealedProductUtils.jsCATEGORY_LABELS_SERVER, SEALED_CATEGORIES, CATEGORY_TO_MSRP_TYPE — 17 keys, the de facto source of truth for the import path
server/models/SealedProduct.jsthe category enum — 21 values, a superset; set_booster, planeswalker_deck, from_the_vault, secret_lair are never emitted
server/schemas/queries.jsa hardcoded 17-value SEALED_CATEGORIES for the /sealed-products filter
client/src/pages/catalog/CatalogSealedPage.jsxCATEGORY_LABELSnot 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.

KeyMTGJSON categoryMTGJSON subtypes
booster_packbooster_packdraft, play, set, default, six-card, promotional, welcome, advanced, premium, topper, other
collector_boosterbooster_packcollector
theme_boosterbooster_packtheme
jumpstart_boosterbooster_packjumpstart
booster_boxbooster_boxdraft, play, set, default, premium, theme, jumpstart
collector_booster_boxbooster_boxcollector
booster_casebooster_caseall except collector
collector_booster_casebooster_casecollector — added 2026-08-09, closing gate 2
bundlebundledefault, fat_pack, premium
gift_bundlebundlegift_bundle
bundle_casebundle_caseall
commander_deckdeck, subsetcommander
planeswalker_deckdeckplaneswalker
deckdecktheme, intro, starter_deck, duel, challenger, event, championship, welcome, planechase
deck_displaydeck_boxall
multiple_decksmultiple_decks, subsetall others
two_player_startermultiple_decks, deck_boxtwo_player_starter
prerelease_packlimited_aid_tool, booster_pack, subsetprerelease_kit
limited_aid_toollimited_aid_tooldraft_set, tournament_deck, starter_deck
limited_aid_caselimited_aid_caseall
secret_lairbox_setsecret_lair, secret_lair_bundle
beginner_boxbox_setstarter_deck
box_setbox_setall others, including absent subtype
kitkitdeck_builders_toolkit, guild_kit, land_station
otherunknownall; 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 null when plugin.hasSealedCategoryTaxonomy is false. Gating inside this one function is what resolves the Pokemon contradiction — one gate rather than six scattered checks at call sites.
  • Returns other for 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:

  1. Every value in CATEGORY_TO_MSRP_TYPE is a valid SealedProductMSRP.productType. This drift test already exists and must keep passing.
  2. Every productType is 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:

productTypeStatusReason
specialty_bundleRetiredAlready unreachable on main; no key has ever mapped to it
pioneer_deckRetiredIts key was never emitted by the name regex, and MTGJSON has no pioneer subtype
starter_collectionRetiredIts 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_pack from draft_booster (a rename, so the value carries directly)
  • booster_case from booster_box
  • bundle_case from bundle
  • secret_lair, box_set, deck, deck_display, multiple_decks, limited_aid_tool from other
  • kit from deck_builder_toolkit
  • limited_aid_case from prerelease_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.category becomes optional and its enum is updated to the 25 keys. It is required: true today, so Pokemon and Riftbound returning null is 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:

GameTreatment
mtgFull taxonomy. hasSealedCategoryTaxonomy: true, derives from the pair table.
pokemonStops 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.
riftboundIdentical 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 other unintentionally.
  • The MSRP drift test in both directions (section 3).
  • Schema round-trip tests for the category changes (section 6).
  • Migration tests covering dry-run, idempotency, the not-already-set guard, and orphan removal.
  • Per-game tests asserting deriveSealedCategory returns null for pokemon and riftbound, and that their transforms omit sealed_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.

PRContentsMerchant-visible effect
1Vocabulary, derivation table, per-game gate, MSRP map, schema changes, all four vocabulary copies unifiedNew imports get correct categories; Pokemon stops getting MTG's
2Merchant rate migrationA new booster_case row appears, pre-seeded; quotes unchanged
3Backfill phases 1 and 2Existing 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.

  1. play_booster_case has 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_case is a valid SealedProductMSRP.productType, but the collection has no rows for it and scripts/data-loading/seedSealedProductMSRP.js seeds none. Until Brent supplies case MSRPs, the fix is a correction (stop being wrong), not a completion (start being right).
  2. Collector cases would price at the play-case MSRP. RESOLVED 2026-08-09collector_booster_case was added as a 25th vocabulary key. The pair booster_case|collector (49 real products) now resolves to collector_booster_case and maps to its own SealedProductMSRP.productType of 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:

  1. 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.
  2. Nine minor deck subtypes folded into deck. theme (206 products) and intro (160) are the largest and could be split out later without disturbing the others.