Skip to content

Sealed Official Taxonomy — PR1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Derive sealed product categories from MTGJSON's official (category, subtype) pair instead of a product-name regex, and stop applying MTG's taxonomy to Pokemon and Riftbound.

Architecture: One lookup table in server/utils/sealedProductUtils.js maps the MTGJSON pair to a 24-key vocabulary, matched most-specific-first (exact pair, then category wildcard). A single exported function deriveSealedCategory(plugin, product) replaces categorizeProduct(name) at all six call sites and returns null for games whose plugin declares no taxonomy. The name regex is deleted.

Tech Stack: Node.js CommonJS, Mongoose, Zod, Vitest, React 18.

Spec: docs/superpowers/specs/2026-08-06-sealed-official-taxonomy-design.md

Global Constraints

  • Server source is CommonJS (require/module.exports). Test files use ESM (import) with createRequire for server modules — copy the header of server/routes/sealedProducts.buildSyncPayload.test.js.
  • Never vi.mock a server module. vitest.config.js externalizes all non-test server source to native require(), so such a mock is silently inert. Use _setDeps()/_resetDeps() or vi.spyOn on the exported object.
  • No identity defaults. Never default game, shop, or a plugin. A missing one is an error, not a fallback (CLAUDE.md 5.5).
  • Never index an object with a runtime-derived key. Use Map.get(). ESLint's security/detect-object-injection is on, and the existing code uses CATEGORY_TO_MSRP_TYPE_MAP for exactly this reason.
  • Tests are co-located: x.test.js beside x.js.
  • Commit messages: one imperative sentence, sentence case, no trailing period, stating the merchant-visible outcome. End with Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>.
  • Never use --no-verify. The husky pre-commit hook runs ESLint; fix lint rather than bypassing it.
  • Out of scope for PR1: migrating merchants' categoryRates (PR2), backfilling existing documents or Shopify metafields (PR3), and aligning GET /catalog/sealed browse to this vocabulary (follow-up). Do not touch routes/catalog.js's category projection or filter.

File Structure

FileResponsibilityChange
server/utils/sealedProductUtils.jsThe single vocabulary definition: pair table, labels, MSRP map, derivationModify
server/utils/sealedProductUtils.test.jsDerivation, fold, and both-direction MSRP drift testsModify
server/models/SealedProduct.jscategory enum widened to 24 keys and made optionalModify
server/models/SealedProduct.test.jsRound-trip tests for the schema changeModify
server/models/SealedProductMSRP.jsproductType enum gains play_booster_caseModify
server/plugins/mtg/index.jsTransform uses deriveSealedCategoryModify
server/plugins/pokemon/index.jsStops deriving a categoryModify
server/plugins/riftbound/index.jsStops deriving a categoryModify
server/routes/sealedProducts.jsTwo call sites switch to deriveSealedCategoryModify
server/routes/catalog.jsPricing call site only (line ~844)Modify
server/services/buylistQuoteService.jsInjected dep renamedModify
server/scripts/data-loading/updateSealedProductPrices.jsMSRP fallback derivation + stale commentModify
server/scripts/data-loading/testSealedPricing.jsDiagnostic script call siteModify
server/schemas/queries.jsImports the vocabulary instead of hardcoding itModify
client/src/pages/catalog/CatalogSealedPage.jsxLabel map gains the new keysModify

Task 1: The pair table and deriveSealedCategory

Files:

  • Modify: server/utils/sealedProductUtils.js
  • Test: server/utils/sealedProductUtils.test.js

Interfaces:

  • Consumes: nothing.

  • Produces:

    • deriveSealedCategory(plugin, product) -> string | null where plugin is a game plugin instance (only hasSealedCategoryTaxonomy is read) and product is an MTGJSON sealed product document (only category and subtype are read).
    • SEALED_CATEGORY_BY_PAIR: Map<string, string> and SEALED_CATEGORY_BY_MTGJSON_CATEGORY: Map<string, string> (exported for tests only).
  • [ ] Step 1: Write the failing tests

Append to server/utils/sealedProductUtils.test.js:

javascript
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const { deriveSealedCategory } = require('./sealedProductUtils.js');

const mtg = { hasSealedCategoryTaxonomy: true };
const pokemon = { hasSealedCategoryTaxonomy: false };

describe('deriveSealedCategory — exact pair wins over category wildcard', () => {
    // Every expectation below is a real (category, subtype) pair observed in
    // the local mtg_sets catalog. Counts are from the spec.
    it.each([
        ['booster_box', 'collector', 'collector_booster_box'],
        ['booster_box', 'draft', 'booster_box'],
        ['booster_case', 'collector', 'booster_case'],
        ['booster_case', 'draft', 'booster_case'],
        ['booster_pack', 'collector', 'collector_booster'],
        ['booster_pack', 'theme', 'theme_booster'],
        ['booster_pack', 'jumpstart', 'jumpstart_booster'],
        ['booster_pack', 'draft', 'booster_pack'],
        ['booster_pack', 'play', 'booster_pack'],
        ['bundle', 'gift_bundle', 'gift_bundle'],
        ['bundle', 'fat_pack', 'bundle'],
        ['bundle_case', 'default', 'bundle_case'],
        ['deck', 'commander', 'commander_deck'],
        ['deck', 'planeswalker', 'planeswalker_deck'],
        ['deck', 'theme', 'deck'],
        ['deck', 'intro', 'deck'],
        ['deck_box', 'two_player_starter', 'two_player_starter'],
        ['deck_box', 'theme', 'deck_display'],
        ['subset', 'commander', 'commander_deck'],
        ['subset', 'theme', 'multiple_decks'],
        ['multiple_decks', 'two_player_starter', 'two_player_starter'],
        ['multiple_decks', 'duel', 'multiple_decks'],
        ['limited_aid_tool', 'prerelease_kit', 'prerelease_pack'],
        ['limited_aid_tool', 'draft_set', 'limited_aid_tool'],
        ['limited_aid_case', 'prerelease_kit', 'limited_aid_case'],
        ['box_set', 'secret_lair', 'secret_lair'],
        ['box_set', 'secret_lair_bundle', 'secret_lair'],
        ['box_set', 'starter_deck', 'beginner_box'],
        ['box_set', 'from_the_vault', 'box_set'],
        ['kit', 'deck_builders_toolkit', 'kit'],
        ['unknown', 'unknown', 'other'],
    ])('%s + %s -> %s', (category, subtype, expected) => {
        expect(deriveSealedCategory(mtg, { category, subtype })).toBe(expected);
    });
});

describe('deriveSealedCategory — absent subtype', () => {
    // 196 products carry a category with no subtype at all (largest group:
    // box_set, e.g. "Tenth Edition MTGO Redemption").
    it('falls back to the category wildcard when subtype is missing', () => {
        expect(deriveSealedCategory(mtg, { category: 'box_set' })).toBe('box_set');
    });

    it('falls back to the category wildcard when subtype is null', () => {
        expect(deriveSealedCategory(mtg, { category: 'booster_box', subtype: null })).toBe('booster_box');
    });
});

describe('deriveSealedCategory — unmapped pairs are loud, not silent', () => {
    it('returns other for a category MTGJSON has not used before', () => {
        expect(deriveSealedCategory(mtg, { category: 'sealed_hologram', subtype: 'x' })).toBe('other');
    });

    it('warns so a new MTGJSON category surfaces in importer output', () => {
        const logger = require('./logger.js');
        const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});

        deriveSealedCategory(mtg, { category: 'sealed_hologram', subtype: 'x' });

        expect(warn).toHaveBeenCalledWith(
            'Unmapped MTGJSON sealed category pair',
            expect.objectContaining({ category: 'sealed_hologram', subtype: 'x' })
        );
        warn.mockRestore();
    });

    it('does not warn for a known category with an unlisted subtype', () => {
        const logger = require('./logger.js');
        const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});

        expect(deriveSealedCategory(mtg, { category: 'deck', subtype: 'brand_new' })).toBe('deck');

        expect(warn).not.toHaveBeenCalled();
        warn.mockRestore();
    });
});

describe('deriveSealedCategory — per-game gate (CLAUDE.md 5.1)', () => {
    it('returns null for a plugin with no sealed taxonomy', () => {
        expect(deriveSealedCategory(pokemon, { category: 'booster_box', subtype: 'draft' })).toBeNull();
    });

    it('returns null before reading the product at all', () => {
        expect(deriveSealedCategory(pokemon, undefined)).toBeNull();
    });

    it('throws when no plugin is supplied rather than assuming MTG', () => {
        expect(() => deriveSealedCategory(undefined, { category: 'box_set' }))
            .toThrow('plugin is required to derive a sealed category');
    });
});
  • [ ] Step 2: Run the tests to verify they fail

Run: npx vitest run server/utils/sealedProductUtils.test.js Expected: FAIL — deriveSealedCategory is not a function.

  • [ ] Step 3: Implement the table and function

In server/utils/sealedProductUtils.js, add above module.exports. Note logger is required at the top of the file alongside the existing requires.

javascript
const logger = require('./logger');

/**
 * MTGJSON's own sealedProduct vocabulary -> our category vocabulary.
 *
 * Keyed on the `category|subtype` PAIR, because neither half carries the
 * commercial distinction alone: a Collector Booster Box is
 * `category: 'booster_box', subtype: 'collector'`, and a gift bundle is
 * `category: 'bundle', subtype: 'gift_bundle'`. (This is the same observation
 * that previously argued for deriving from the product NAME — see the comment
 * in scripts/data-loading/updateSealedProductPrices.js. The pair carries it;
 * the raw category does not.)
 *
 * Every entry corresponds to a pair observed in the live MTGJSON catalog
 * (CLAUDE.md 5.4 — no invented literals).
 */
const SEALED_CATEGORY_BY_PAIR = new Map([
    ['booster_pack|collector', 'collector_booster'],
    ['booster_pack|theme', 'theme_booster'],
    ['booster_pack|jumpstart', 'jumpstart_booster'],
    ['booster_pack|prerelease_kit', 'prerelease_pack'],
    ['booster_box|collector', 'collector_booster_box'],
    ['bundle|gift_bundle', 'gift_bundle'],
    ['deck|commander', 'commander_deck'],
    ['deck|planeswalker', 'planeswalker_deck'],
    ['deck_box|two_player_starter', 'two_player_starter'],
    ['subset|commander', 'commander_deck'],
    ['subset|prerelease_kit', 'prerelease_pack'],
    ['multiple_decks|two_player_starter', 'two_player_starter'],
    ['limited_aid_tool|prerelease_kit', 'prerelease_pack'],
    ['box_set|secret_lair', 'secret_lair'],
    ['box_set|secret_lair_bundle', 'secret_lair'],
    ['box_set|starter_deck', 'beginner_box'],
]);

/**
 * Fallback when the exact pair is not listed: every MTGJSON category maps to a
 * default key. A known category with a brand-new subtype resolves here rather
 * than falling to `other`.
 */
const SEALED_CATEGORY_BY_MTGJSON_CATEGORY = new Map([
    ['booster_pack', 'booster_pack'],
    ['booster_box', 'booster_box'],
    ['booster_case', 'booster_case'],
    ['bundle', 'bundle'],
    ['bundle_case', 'bundle_case'],
    ['deck', 'deck'],
    ['deck_box', 'deck_display'],
    ['subset', 'multiple_decks'],
    ['multiple_decks', 'multiple_decks'],
    ['limited_aid_tool', 'limited_aid_tool'],
    ['limited_aid_case', 'limited_aid_case'],
    ['box_set', 'box_set'],
    ['kit', 'kit'],
    ['unknown', 'other'],
]);

/**
 * Derive our sealed category from an MTGJSON sealed product.
 *
 * The per-game gate lives here rather than at each call site: Pokemon and
 * Riftbound declare hasSealedCategoryTaxonomy false, and previously called the
 * name regex anyway, which labelled a Pokemon Sleeved Booster Pack a "Draft
 * Booster" (CLAUDE.md 5.1).
 *
 * @param {{hasSealedCategoryTaxonomy: boolean}} plugin - Game plugin. Required;
 *   never defaulted (CLAUDE.md 5.5)
 * @param {{category?: string, subtype?: string}} product - MTGJSON sealed product
 * @returns {string|null} A SEALED_CATEGORIES key, or null for games with no taxonomy
 */
function deriveSealedCategory(plugin, product) {
    if (!plugin) {
        throw new Error('plugin is required to derive a sealed category');
    }
    if (!plugin.hasSealedCategoryTaxonomy) {
        return null;
    }

    const category = product?.category;
    if (!category) return 'other';

    const subtype = product?.subtype;
    if (subtype) {
        const exact = SEALED_CATEGORY_BY_PAIR.get(`${category}|${subtype}`);
        if (exact) return exact;
    }

    const byCategory = SEALED_CATEGORY_BY_MTGJSON_CATEGORY.get(category);
    if (byCategory) return byCategory;

    // A category MTGJSON has not used before. Land in `other`, but say so:
    // silently absorbing it is how a whole new product class disappears.
    logger.warn('Unmapped MTGJSON sealed category pair', { category, subtype });
    return 'other';
}

Add deriveSealedCategory, SEALED_CATEGORY_BY_PAIR and SEALED_CATEGORY_BY_MTGJSON_CATEGORY to module.exports. Leave categorizeProduct exported for now — Task 5 removes it once every caller has moved.

  • [ ] Step 4: Run the tests to verify they pass

Run: npx vitest run server/utils/sealedProductUtils.test.js Expected: PASS, all tests.

  • [ ] Step 5: Commit
bash
git add server/utils/sealedProductUtils.js server/utils/sealedProductUtils.test.js
git commit -m "Derive sealed categories from MTGJSON's official category and subtype

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 2: Widen the vocabulary and the MSRP map

Files:

  • Modify: server/utils/sealedProductUtils.js, server/models/SealedProductMSRP.js
  • Test: server/utils/sealedProductUtils.test.js

Interfaces:

  • Consumes: deriveSealedCategory from Task 1.

  • Produces: CATEGORY_LABELS_SERVER and SEALED_CATEGORIES at 24 keys; RETIRED_MSRP_TYPES: string[].

  • [ ] Step 1: Write the failing tests

javascript
const {
    CATEGORY_LABELS_SERVER, SEALED_CATEGORIES, CATEGORY_TO_MSRP_TYPE,
    RETIRED_MSRP_TYPES, mapCategoryToMsrpType,
    SEALED_CATEGORY_BY_PAIR, SEALED_CATEGORY_BY_MTGJSON_CATEGORY,
} = require('./sealedProductUtils.js');
const SealedProductMSRP = require('../models/SealedProductMSRP.js');

describe('sealed vocabulary', () => {
    it('has a label for all 24 keys', () => {
        expect(SEALED_CATEGORIES).toHaveLength(24);
        for (const key of SEALED_CATEGORIES) {
            expect(CATEGORY_LABELS_SERVER[key]).toBeTruthy();
        }
    });

    it('includes the two new keys the change exists for', () => {
        expect(SEALED_CATEGORIES).toContain('booster_case');
        expect(SEALED_CATEGORIES).toContain('secret_lair');
    });

    it('retires the keys the regex could never emit', () => {
        expect(SEALED_CATEGORIES).not.toContain('draft_booster');
        expect(SEALED_CATEGORIES).not.toContain('scene_box');
        expect(SEALED_CATEGORIES).not.toContain('pioneer_deck');
    });

    // Every value either table can produce must be a real key, or the
    // derivation can emit something the enum and the rate schema reject.
    it('every derivable value is a known key', () => {
        const derivable = new Set([
            ...SEALED_CATEGORY_BY_PAIR.values(),
            ...SEALED_CATEGORY_BY_MTGJSON_CATEGORY.values(),
        ]);
        for (const value of derivable) {
            expect(SEALED_CATEGORIES).toContain(value);
        }
    });
});

describe('MSRP drift — both directions', () => {
    const validTypes = SealedProductMSRP.schema.path('productType').enumValues;

    it('every mapped target is a valid productType', () => {
        for (const target of Object.values(CATEGORY_TO_MSRP_TYPE)) {
            expect(validTypes).toContain(target);
        }
    });

    // The new guard. Without it, folding a key silently REDUCES MSRP coverage
    // while the change is nominally about raising it.
    it('every productType is reachable from a key or explicitly retired', () => {
        const reachable = new Set(Object.values(CATEGORY_TO_MSRP_TYPE));
        const unreachable = validTypes.filter(
            (t) => !reachable.has(t) && !RETIRED_MSRP_TYPES.includes(t)
        );
        expect(unreachable).toEqual([]);
    });

    it('booster cases no longer resolve a box MSRP — the money bug', () => {
        expect(mapCategoryToMsrpType('booster_case')).toBe('play_booster_case');
        expect(mapCategoryToMsrpType('booster_case')).not.toBe('play_booster_box');
    });
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/utils/sealedProductUtils.test.js Expected: FAIL — SEALED_CATEGORIES has 17 entries, RETIRED_MSRP_TYPES undefined.

  • [ ] Step 3: Implement

Replace CATEGORY_LABELS_SERVER in server/utils/sealedProductUtils.js:

javascript
const CATEGORY_LABELS_SERVER = {
    booster_pack: 'Booster Pack',
    collector_booster: 'Collector Booster',
    theme_booster: 'Theme Booster',
    jumpstart_booster: 'Jumpstart Booster',
    booster_box: 'Booster Box',
    collector_booster_box: 'Collector Booster Box',
    booster_case: 'Booster Case',
    bundle: 'Bundle',
    gift_bundle: 'Gift Bundle',
    bundle_case: 'Bundle Case',
    commander_deck: 'Commander Deck',
    planeswalker_deck: 'Planeswalker Deck',
    deck: 'Deck',
    deck_display: 'Deck Display',
    multiple_decks: 'Multiple Decks',
    two_player_starter: 'Two-Player Starter',
    prerelease_pack: 'Prerelease Pack',
    limited_aid_tool: 'Limited Aid Tool',
    limited_aid_case: 'Limited Aid Case',
    secret_lair: 'Secret Lair',
    beginner_box: 'Beginner Box',
    box_set: 'Box Set',
    kit: 'Kit',
    other: 'Other',
};

Replace CATEGORY_TO_MSRP_TYPE:

javascript
const CATEGORY_TO_MSRP_TYPE = {
    booster_pack: 'play_booster_pack',
    collector_booster: 'collector_booster_pack',
    theme_booster: 'theme_booster',
    jumpstart_booster: 'jumpstart_booster',
    booster_box: 'play_booster_box',
    collector_booster_box: 'collector_booster_box',
    booster_case: 'play_booster_case',
    bundle: 'bundle',
    gift_bundle: 'bundle_gift',
    commander_deck: 'commander_deck',
    planeswalker_deck: 'planeswalker_deck',
    two_player_starter: 'two_player_starter',
    prerelease_pack: 'prerelease_pack',
    beginner_box: 'beginner_box',
    kit: 'deck_builder_toolkit',
};

/**
 * MSRP productTypes with no category key, each retired deliberately.
 *
 * `specialty_bundle` was ALREADY unreachable before this change — no key has
 * ever mapped to it. `pioneer_deck`'s key was never emitted by the old name
 * regex and MTGJSON has no `pioneer` subtype. `starter_collection`'s products
 * carry `box_set` in MTGJSON and fold into the `box_set` key.
 *
 * The reachability test above asserts reachable + retired covers every enum
 * value, so stranding a live type fails the build.
 */
const RETIRED_MSRP_TYPES = ['specialty_bundle', 'pioneer_deck', 'starter_collection'];

In server/models/SealedProductMSRP.js, add 'play_booster_case' to the productType enum, after 'collector_booster_box'.

Export RETIRED_MSRP_TYPES.

  • [ ] Step 4: Run to verify pass

Run: npx vitest run server/utils/sealedProductUtils.test.js server/models/SealedProductMSRP.test.js Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/utils/sealedProductUtils.js server/utils/sealedProductUtils.test.js server/models/SealedProductMSRP.js
git commit -m "Give booster cases their own category so they stop pricing at a box MSRP

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 3: Move every caller onto deriveSealedCategory

Files:

  • Modify: server/plugins/mtg/index.js (~line 480), server/plugins/pokemon/index.js (~581), server/plugins/riftbound/index.js (~433), server/routes/sealedProducts.js (~113 and ~676), server/routes/catalog.js (~844), server/services/buylistQuoteService.js (~441 and ~930), server/scripts/data-loading/updateSealedProductPrices.js (~220), server/scripts/data-loading/testSealedPricing.js (~99)
  • Test: server/plugins/mtg/index.test.js, server/plugins/pokemon/index.test.js, server/plugins/riftbound/index.test.js, server/services/buylistQuoteService.test.js

Interfaces:

  • Consumes: deriveSealedCategory(plugin, product) from Task 1.

  • Produces: no new exports. buylistQuoteService's injected dep categorizeProduct is renamed to deriveSealedCategory and now takes (plugin, product).

  • [ ] Step 1: Write the failing tests

In server/plugins/mtg/index.test.js:

javascript
it('derives the category from MTGJSON category and subtype, not the name', () => {
    // Name says "Booster Box"; the pair says it is a CASE. The pair wins.
    const product = {
        uuid: 'u1', name: 'Tenth Edition Booster Box Case',
        category: 'booster_case', subtype: 'draft',
    };
    const setData = { code: '10E', name: 'Tenth Edition', releaseDate: '2007-07-13' };

    const result = plugin.transformSealedToProduct(product, setData);

    expect(result.category).toBe('booster_case');
});

it('classifies a Secret Lair as secret_lair rather than other', () => {
    const product = {
        uuid: 'u2', name: 'Behold New Phyrexia Limited Edition Set',
        category: 'box_set', subtype: 'secret_lair',
    };
    const result = plugin.transformSealedToProduct(product, { code: 'SLD', name: 'Secret Lair' });

    expect(result.category).toBe('secret_lair');
});

In server/plugins/pokemon/index.test.js (and the identical test in riftbound/index.test.js):

javascript
it('does not apply MTG\'s taxonomy to a Pokemon sealed product', () => {
    const product = { uuid: '123', name: 'Journey Together Sleeved Booster Pack' };
    const setData = { code: 'JTG', name: 'Journey Together', releaseDate: '2025-03-28' };

    const result = plugin.transformSealedToProduct(product, setData);

    // Previously "draft_booster" — a category Pokemon does not have.
    expect(result.category).toBeNull();
});

it('omits sealed_type rather than sending a wrong one', () => {
    const result = plugin.transformSealedToProduct(
        { uuid: '123', name: 'Journey Together Elite Trainer Box' },
        { code: 'JTG', name: 'Journey Together' }
    );

    expect(result.metafields).not.toHaveProperty('sealed_type');
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/plugins/ Expected: FAIL — MTG returns booster_box for the case; Pokemon returns draft_booster instead of null.

  • [ ] Step 3: Implement

In each plugin's transformSealedToProduct, replace categorizeProduct(name) with:

javascript
const { deriveSealedCategory, CATEGORY_LABELS_SERVER } = require('../../utils/sealedProductUtils');
const category = deriveSealedCategory(this, product);

MTG's description line uses the label; guard it since category can now be null:

javascript
if (category) {
    descriptionParts.push(`<p><strong>Product Type:</strong> ${CATEGORY_LABELS_SERVER[category] || category}</p>`);
}

In server/routes/sealedProducts.js, both priceInputs blocks become:

javascript
category: deriveSealedCategory(plugin, product)

buildSealedSyncPayload already reads transformed.category; its sealed_type line must skip a null category:

javascript
if (category) {
    metafields.sealed_type = CATEGORY_LABELS_SERVER[category] || category;
}

In server/routes/catalog.js (~844) only the game string is in scope — the sole getPlugin call in that handler builds a riftboundPlugin inside a different branch, so do not reuse it. Resolve the plugin once, outside the .map(), so it is not re-resolved per product:

javascript
const { getPlugin } = require('../plugins');
const sealedPlugin = getPlugin(game);   // `game` is required by catalogSealedQuerySchema
const pricePromises = sealedProducts.map((product) => {
    const category = deriveSealedCategory(sealedPlugin, product);
    return getSealedProductPrice(product.uuid, product.setCode, category, { game });
});

Note this handler's sealedProducts come from an aggregation that projects category: '$data.sealedProduct.category' — the raw MTGJSON value — but not subtype. Add subtype: '$data.sealedProduct.subtype' to that $project stage, or every product here resolves through the category wildcard and collector boosters silently price as plain ones. This is the single easiest thing in the plan to get wrong.

In server/services/buylistQuoteService.js, rename the injected dep and drop the now-redundant flag check — the gate lives inside the function:

javascript
deriveSealedCategory: require('../utils/sealedProductUtils').deriveSealedCategory,
javascript
const category = deps.deriveSealedCategory(plugin, product);

In updateSealedProductPrices.js the plugin is not in scope, but product.game is — buildSealedProductMap stamps it:

javascript
const { getPlugin } = require('../../plugins');
const productType = mapCategoryToMsrpType(deriveSealedCategory(getPlugin(product.game), product));

In testSealedPricing.js there is no product.game field — the script is MTG-only and passes the literal 'mtg' to getSealedProductPrice at line ~103. Use the same literal, which is a script-scope constant rather than a defaulted identity parameter:

javascript
const { getPlugin } = require('../../plugins');
const mtgPlugin = getPlugin('mtg');   // this diagnostic script is MTG-only
// ...
const category = deriveSealedCategory(mtgPlugin, product);

Check that the loop in that script actually has the MTGJSON product object in scope, not just name — if it destructures only the name, widen it to the whole product.

  • [ ] Step 4: Run to verify pass

Run: npx vitest run server/plugins/ server/routes/ server/services/buylistQuoteService.test.js Expected: PASS. Some existing tests will need their fixtures given category/subtype; update the fixture, never the assertion.

  • [ ] Step 5: Commit
bash
git add server/plugins/ server/routes/sealedProducts.js server/routes/catalog.js server/services/buylistQuoteService.js server/scripts/data-loading/
git commit -m "Stop applying MTG's sealed taxonomy to Pokemon and Riftbound products

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 4: Schema changes and round-trip tests

Files:

  • Modify: server/models/SealedProduct.js
  • Test: server/models/SealedProduct.test.js

Interfaces:

  • Consumes: SEALED_CATEGORIES from Task 2.

  • Produces: SealedProduct.category optional, enum of 24 keys.

  • [ ] Step 1: Write the failing tests

javascript
describe('SealedProduct.category — schema shape (CLAUDE.md 5.2)', () => {
    const base = { shop: 's.myshopify.com', game: 'mtg', uuid: 'u1', name: 'X', setCode: 'DFT' };

    it('accepts a document with no category, for games with no taxonomy', () => {
        const doc = new SealedProduct({ ...base, game: 'pokemon', category: null });
        expect(doc.validateSync()).toBeUndefined();
    });

    it('round-trips the new booster_case value', () => {
        const doc = new SealedProduct({ ...base, category: 'booster_case' });
        expect(doc.validateSync()).toBeUndefined();
        expect(doc.category).toBe('booster_case');
    });

    it('round-trips secret_lair', () => {
        const doc = new SealedProduct({ ...base, category: 'secret_lair' });
        expect(doc.validateSync()).toBeUndefined();
        expect(doc.category).toBe('secret_lair');
    });

    it('rejects a value outside the vocabulary', () => {
        const doc = new SealedProduct({ ...base, category: 'not_a_category' });
        expect(doc.validateSync()?.errors?.category).toBeDefined();
    });

    it('accepts every key in the shared vocabulary', () => {
        for (const key of SEALED_CATEGORIES) {
            const doc = new SealedProduct({ ...base, category: key });
            expect(doc.validateSync()?.errors?.category).toBeUndefined();
        }
    });
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/models/SealedProduct.test.js Expected: FAIL — category is required, and booster_case is not in the enum.

  • [ ] Step 3: Implement

In server/models/SealedProduct.js, import the vocabulary and replace the field:

javascript
const { SEALED_CATEGORIES } = require('../utils/sealedProductUtils');
javascript
    // Optional: games whose plugin declares hasSealedCategoryTaxonomy false
    // (pokemon, riftbound) store no category rather than borrowing MTG's.
    // Enum imported, never re-listed — a second copy is how the vocabulary
    // drifted across four files in the first place (CLAUDE.md 5.8).
    category: {
        type: String,
        enum: SEALED_CATEGORIES,
    },
  • [ ] Step 4: Run to verify pass

Run: npx vitest run server/models/SealedProduct.test.js Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/models/SealedProduct.js server/models/SealedProduct.test.js
git commit -m "Let sealed products from games without a taxonomy store no category

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 5: Delete the name regex and unify the remaining copies

Files:

  • Modify: server/utils/sealedProductUtils.js, server/utils/sealedProductUtils.test.js, server/schemas/queries.js, client/src/pages/catalog/CatalogSealedPage.jsx, server/scripts/data-loading/updateSealedProductPrices.js
  • Test: client/src/pages/catalog/CatalogSealedPage.test.jsx

Interfaces:

  • Consumes: SEALED_CATEGORIES from Task 2.

  • Produces: categorizeProduct no longer exists.

  • [ ] Step 1: Prove no caller remains

Run: grep -rn "categorizeProduct" server/ client/ --include=*.js --include=*.jsx Expected: only the definition and its own tests in sealedProductUtils. Any other hit means Task 3 missed a call site — fix that first.

  • [ ] Step 2: Delete it

Remove categorizeProduct and its tests from server/utils/sealedProductUtils.js and sealedProductUtils.test.js, and drop it from module.exports. It has no fallback role: MTGJSON category is present on 100% of sealed products.

  • [ ] Step 3: Replace the duplicated vocabulary in queries.js

Delete the hardcoded 17-value array at server/schemas/queries.js:24 and import instead:

javascript
const { SEALED_CATEGORIES } = require('../utils/sealedProductUtils');

Keep the existing category: z.enum(SEALED_CATEGORIES).optional(). Zod needs a non-empty tuple, so if z.enum rejects the plain array use z.enum([...SEALED_CATEGORIES]).

  • [ ] Step 4: Add the new keys to the client label map

In client/src/pages/catalog/CatalogSealedPage.jsx, add to CATEGORY_LABELS:

javascript
  collector_booster_box: 'Collector Booster Box',
  deck_display: 'Deck Display',
  planeswalker_deck: 'Planeswalker Deck',
  secret_lair: 'Secret Lair',
  theme_booster: 'Theme Booster',
  two_player_starter: 'Two-Player Starter',
  prerelease_pack: 'Prerelease Pack',
  beginner_box: 'Beginner Box',
  gift_bundle: 'Gift Bundle',
  jumpstart_booster: 'Jumpstart Booster',
  collector_booster: 'Collector Booster',

Do not remove subset, unknown, deck_box, draft_booster or scene_box. This map also labels GET /catalog/sealed, which still returns raw MTGJSON categories; deleting those keys regresses the browse page. Add a comment saying so.

  • [ ] Step 5: Correct the stale comment

In server/scripts/data-loading/updateSealedProductPrices.js, the getMSRPFallback docblock argues the product NAME carries the collector/play distinction. Replace that reasoning — the (category, subtype) pair carries it; only the bare category does not. Leaving it invites a future reader to revert Task 3 on its authority.

  • [ ] Step 6: Run everything

Run: npm test && npm run test:client && npm run lint && npm run build Expected: all pass, zero new lint warnings.

  • [ ] Step 7: Commit
bash
git add -A
git commit -m "Retire the sealed product name regex now that MTGJSON's taxonomy drives categories

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 6: Full verification against the definition of done

Files: none modified unless a check fails.

  • [ ] Step 1: Full suite

Run: npm test Expected: exit 0, zero failures.

  • [ ] Step 2: Client suite and build

Run: npm run test:client && npm run build Expected: both exit 0.

  • [ ] Step 3: Coverage on modified files

Run: npm run test:coverage Read the per-file table yourself — the threshold gate is inoperative under Vitest 4. server/utils/sealedProductUtils.js must be at or above 70% on all four metrics; it is a pure-logic file with no excuse to be lower.

  • [ ] Step 4: Lint

Run: npm run lint Expected: exit 0. Compare the warning count for each modified file against git stash-ed baseline; zero new warnings.

  • [ ] Step 5: Grep proofs
bash
grep -rn "categorizeProduct" server/ client/ --include=*.js --include=*.jsx   # expect: no matches
grep -rn "|| 'mtg'\|?? 'mtg'" server/utils/sealedProductUtils.js               # expect: no matches
  • [ ] Step 6: Per-game parity statement (CLAUDE.md 5.1)

Write one line per registered plugin in the PR description: mtg — full taxonomy from the pair table; pokemon — exempt, no taxonomy, category null; riftbound — exempt, same. A PR description naming only one game is the failure this rule exists to catch.


Self-review notes

  • Spec coverage. Spec sections 1, 2, 3, 6, 7 and 8 map to Tasks 1–6. Sections 4 (merchant rate migration) and 5 (backfill) are deliberately PR2 and PR3 and are named as out of scope in Global Constraints.
  • Known gap, deliberate. The 24-key vocabulary folds all booster_case subtypes into one key, so a collector booster case resolves play_booster_case. Splitting collector_booster_case out would be a 25th key. Flagged rather than silently absorbed; revisit if case MSRPs land far apart.
  • PR2 depends on Task 2's key list — the plurality seeding maps old keys to the exact names in CATEGORY_LABELS_SERVER above.