RFC: Product-level catalog API (base products + fan-out)

Add product-level createBaseProduct / updateBaseProduct / ... procedures that fan out one author payload into N catalog_product SKU rows — without introducing a new table.

PR dealops#6087 Author @pk675 Ticket DEA-6552 Status Draft RFC Door Two-way Files 2 (+219 / −0) Kind Docs only

What it adds

A product-level tRPC surface (createBaseProduct, updateBaseProduct, getBaseProduct, listBaseProducts, deleteBaseProduct) on the existing v3Admin.catalog router. Author one payload, get N fanned SKU drafts.

What it preserves

No schema change. A base product remains derived — just baseProductId denormalized across sibling rows. Live/draft model and publishDrafts are untouched.

What it ships now

Only RFC + glossary. No code, no tests. The PR is design artifacts from a /grill-with-docs session, intended to unblock implementation in a follow-up.

Common attribute
Distinguishing attribute (pricing dimension)
Fanned-out SKU row
Open question / gap

1. Why this exists

Today's v3Admin.catalog.* router is SKU-grained. createProduct's own comment says so: "Creating a single SKU here; 'create a whole product with N SKUs' is a separate op."

But real catalog items are base products with several priced variants — Voice AI with per-minute / load-tracked / load-booked flavors, or a region × tier grid. Authoring those one row at a time is tedious, error-prone, and the API offers no concept of "the common parts are shared." This RFC defines that missing product-level surface.

Vocabulary, in one breath
Base product
Derived grouping — baseProductId shared across sibling SKU rows. Not a table.
Variant (SKU)
One catalog_product row. Invariant: SKU == unique price.
Common attr
Shared verbatim across all variants. May be scalar or array.
Distinguishing
Flagged unique_price: true. The fan-out dimensions.
Fan-out
Full cartesian product of distinguishing dimensions → one variant per combination.

2. The fan-out, made concrete

Given region: ['US', 'EU'] distinguishing and tier: ['gold', 'silver', 'bronze'] distinguishing, plus common attributes like productLine: 'usage' common, the backend stages 2 × 3 = 6 draft SKU rows. The author must supply exactly 6 prices, one per cell:

tier: gold
tier: silver
tier: bronze
region: US
US · gold$120
US · silver$90
US · bronze$60
region: EU
EU · gold€110
EU · silver€82
EU · bronze€55

Each cell becomes one catalog_product draft row. Its attribute map is { ...common, ...thisCombination }, split by key into systemAttributes / customAttributes via the existing productSpecToRow. skuId is deterministic — e.g. voice-ai__region-us__tier-gold — so re-creates are idempotent.

3. The API surface

ProcedureWhat it doesReturns
createBaseProduct Fans cartesian product → stages N draft rows, all-or-nothing. { products: ProductDTO[] }
updateBaseProduct Partial-merge common attrs / rename across every sibling. No prices, no structural changes. { products: ProductDTO[] }
getBaseProduct All variants for one base (preview view draft ?? live). NOT_FOUND if empty. { products: ProductDTO[] }
listBaseProducts Preview variants for the org; client groups by baseProductId. { products: ProductDTO[] }
deleteBaseProduct Stages isDeleted=true on every sibling; batched soft-delete. { skuIds: string[], staged: 'delete' }

The createBaseProduct payload

Note: the attributes block does double duty — entries flagged unique_price become fan-out dimensions; everything else is common. An array-valued common attribute (like listPriceCurrencies) is NOT auto-promoted to a dimension.

createBaseProduct({
  baseProductId: 'voice-ai',
  baseProductName: 'Voice AI',

  attributes: {
    productLine: 'usage',                                 // common (scalar)
    listPriceCurrencies: ['USD', 'EUR'],                  // common (array — NOT a dimension)
    region: { values: ['US', 'EU'], unique_price: true }, // distinguishing
    tier:   { values: ['gold','silver','bronze'], unique_price: true },
  },

  prices: [
    { region: 'US', tier: 'gold', price: { flat_price: { amount: 120, ... } } },
    // … all 6, exactly one per combination …
  ],
})

4. Completeness validation

Before any row is staged, the backend validates the price set against the expected cartesian product. All failures are aggregated into one response — no fail-on-first.

Checks (all-or-nothing)
  1. Expected count = ∏ |values|
  2. Every combination appears exactly once
  3. Each prices[i] names exactly the flagged keys
  4. Each price passes productPriceSchema
  5. All failures aggregated
Sample aggregated error
Expected 6 prices (region[2] × tier[3]); got 5.
  • Missing: { region: "EU", tier: "bronze" }
  • Unrecognized: { region: "APAC", tier: "gold" }
    — "APAC" not in option "region" (allowed: US, EU)
  • Duplicate: { region: "US", tier: "gold" } ×2
  • Entry 4 missing required option "tier"

5. updateBaseProduct — what it deliberately cannot do

Update is scoped to common attributes + rename only. The payload's shape makes price changes structurally impossible — there's nowhere to put them.

CAN edit
  • baseProductName (rewrites denormalized name on every sibling)
  • systemAttributes partial merge
  • customAttributes partial merge
CANNOT edit
  • Prices (not a field — stay on SKU-level API)
  • Add/remove a dimension value (re-fan not in v1)
  • Patch keys that are currently distinguishing dimensions (runtime reject)
  • Delete a key (partial merge only overwrites)

6. Architecture — thin procedures over draft-staging helpers

Same principle as the existing SKU-level CRUD. The base procedures wrap upsertDraftProduct with new base-level helpers:

Atomicity caveat (flagged in RFC). upsertDraftProduct imports the global Prisma client and is not transaction-parametric. Staging N variants all-or-nothing needs either a tx-capable write helper or wrapping the loop in prisma.$transaction — same gap noted in the earlier catalog-product RFC for bulk staging.

7. Key decisions

Derived, not a table

Base product stays a baseProductId + denormalized name. Alt: a real catalog_base_product table. Why this: two-way door, no migration, collapse/publish already keys off baseProductId.

Explicit flag, not inference

unique_price: true is required to mark a dimension. Inferring from "is it an array?" would misclassify legitimate array-valued commons like listPriceCurrencies.

Full cartesian only

No per-cell holes in v1. Removes the "did I enumerate every combination?" foot-gun and lets validation be a clean completeness check.

Existence validation deferred

Whether region exists as an sku AttributeDef and whether its values are allowed is tracked separately in DEA-6324.

8. What this PR doesn't change

9. Open questions

1. Multi-dimensional publish gap — top open question. collapseRowsByBaseProduct + VARIANT_TYPE_MAP (dealops3/catalogProduct/collapseSiblings.ts) is single-discriminator. A region × tier base product can be created via this API but won't publish to V2 — collapse throws. RFC recommendation: generalize collapse to compound predicates in a separate ticket; let this API land first since it only writes rows.
2. Per-cell exclusion (sparse grids). Needed by any near-term org, or genuinely deferrable?
3. Re-fan on update. Adding/removing a dimension value or changing a price — when do we design the declarative desired-state vs patch update?

10. Files in this PR

rfcs/2026-06-23-product-level-catalog-api.md

+139 / new file

The RFC itself: problem framing, current state, full API design (signatures + validation + error shapes), architecture, decisions log, rollout plan, open questions.

CONTEXT.md

+80 / modified

First catalog vocabulary block (base product, variant, common/distinguishing, fan-out, system-vs-custom), relationship rules, and seven dated decision-log entries for 2026-06-23.