Admin Agent goes table-native for the product catalog

The product sub-agent now reads and writes catalog_product rows directly — the legacy pricingSpec.productSpecs + datasheet authoring path is retired for product work.

PR dealops#6010 Author @yijunz166 Base pk/catalog-live-draft-api Files 28 +/− +3151 / −791 Area Dealops 3 / Admin Agent Status Open

What it adds

A full set of table-native LangChain tools in catalogProductTableTools.ts backed by a 1191-line catalogProductContext.ts service: preview reads, field projection, pricebook lookup, single + bulk staging, draft inspection.

What it changes

The Product Catalog sub-agent is rewritten around catalog_product. Publish/discard/draft-status surfaces gain a new catalogProducts layer. updateProduct writes draft table rows instead of editing the pricingSpec blob.

What it removes

The org-scoped feature flag (catalogProduct/flag.ts) is deleted. The catalog table is now the single product source of truth — no fallback to inline blob productSpecs.

What it preserves

V3 → V2 runtime compatibility: assembleV3PricingSpec still produces the same shape, but sources productSpecs from the table and derives datasheet list-price entries from catalog_product.price at sync time.

catalog_product (table, new SoT)
pricingSpec.productSpecs (legacy blob, ignored)
datasheet product list-price (derived only)
Product Catalog sub-agent
Draft / publish lifecycle

1. Why this exists

Product authoring in V3 has lived in two places at once: the inline pricingSpec.productSpecs map (a JSON blob, versioned as a whole) and per-product datasheet datapoints for list pricing. Earlier PRs in this stack (see base branch pk/catalog-live-draft-api) introduced a real SQL table — catalog_product with live/draft states — and started routing reads through it behind an org-scoped allow-list.

This PR removes the seam. The Admin Agent now treats catalog_product as the only product source, the legacy blob is no longer authored, and the read flag is deleted. The RFC at rfcs/2026-06-18-admin-agent-table-backed-product-catalog.md (+839 lines) documents the new boundaries.

Scope guardrail. Attribute schema (the attributes spec) still lives in the versioned-blob world — only product rows moved. Approval rules, flow spec, and non-product pricing config are untouched.

2. Before / after at a glance

Before
  • Sub-agent tools wrote pricingSpec.productSpecs[id] as drafts of the whole pricingSpec blob.
  • Product list prices written as datasheet datapoints with a productId equality predicate.
  • assembleV3PricingSpec branched on catalogProductEnabled(orgId): flag-off returned the legacy blob verbatim.
  • Tool surface: createProduct, updateProductPrice, setProductPriceTiers, bulkWriteProducts, etc.
After
  • Sub-agent tools stage catalog_product draft rows; preview is draft-over-live with tombstones for deletes.
  • Product prices live on catalog_product.price (one of flat_price / tiered_price / composite_price).
  • assembleV3PricingSpec always reads productSpecs from the table; the blob supplies only the non-product envelope.
  • Tool surface: stageCreateCatalogProduct, stageUpdateCatalogProduct, stageUpdateCatalogProductPrice, stageDeleteCatalogProduct, bulkStageCatalogProducts, plus reads listCatalogProductsForAgent / getCatalogProductForAgent / listPricebooksForAgent / listCatalogDraftChangesForAgent.

3. The new product data flow

The orchestrator/planner stays the same — it still delegates product work to the Product Catalog sub-agent. What changed is what tools that sub-agent receives and where its reads and writes land.

Sub-agent
Receives only createCatalogProductTableTools + file-read shared tools. Legacy product tools (createProductCatalogTools, bulkWriteProducts) are no longer injected.
Reads
Preview layer merges draft over live per skuId; staged deletes are filtered out. Field projection (fields: ["skuId","displayName"]) keeps token cost down.
Writes
Every write upserts a single state='draft' row keyed by (orgId, skuId). Deletes are draft rows with isDeleted=true.
Pricebook
If multiple active pricebooks exist and the request didn't pin one, the agent must call listPricebooksForAgent and bounce the choice back to the user. Ambiguity is a hard error, not a silent default.

4. Where the legacy authoring path goes

The biggest behavioral cut is in trpc/router/v3Admin/updateProduct.ts. Its existing public surface is preserved (admins keep editing products in the UI), but the implementation is rewritten:

Before — updateProduct deps
UpdateProductDeps = {
  catalogProductEnabled(orgId): boolean
  loadPricingSpecForEdit(...)
  loadDatasheetForPreview(...)
  saveDraft(slug, user, specType, data)
}

A price edit branched on the flag: flag-on wrote productSpec.price into a pricingSpec draft, flag-off wrote a datasheet draftpoint.

After — updateProduct deps
UpdateProductDeps = {
  loadEffectiveProduct(...)
  upsertDraftProduct(
    orgId, userId, skuId,
    identity, content,
    isDeleted, pricebookId,
  )
}

A price edit writes catalog_product.price.flat_price on a draft row. An attributes edit patches systemAttributes / customAttributes and mirrors isActive onto the row column.

The test file catalogProductUpdateProductAuthoring.test.ts was rewritten to match — its three new cases assert: (1) a list-price edit lands as flat_price on a draft row, (2) attribute-only edits leave price as {}, and (3) attributes.isActive is mirrored onto the table's isActive column.

5. What the sub-agent's tool box looks like now

ToolPurposeNotes
listCatalogProductsForAgentList/preview with filters + field projectionFetch skuId + displayName first; pull price / attributes on demand
getCatalogProductForAgentOne product by skuIdSame projection contract
listPricebooksForAgentActive org pricebooksReads Pricebook table directly
listCatalogDraftChangesForAgentStaged catalog draft inventoryClassifies each row as create / update / delete vs live
stageCreateCatalogProductSingle createRejects unknown attributes; requires name
stageUpdateCatalogProductSingle patchCarries identity + pricebook forward
stageUpdateCatalogProductPricePrice-only editDelegates to stageUpdateCatalogProduct internally
stageDeleteCatalogProductTombstone draftWrites isDeleted=true, doesn't physically delete
bulkStageCatalogProductsJSON bulk upsert/create/updateSupports validateOnly=true for preflight on large imports
Tool input shape — productWriteSchema (single + bulk)
{
  skuId: string,             // stable product id
  name?: string,             // required on create
  skuName?: string | null,
  baseProductId?: string,
  baseProductName?: string,
  attributes?: Record<string, AttributeValue>,
  structuralFields?: {       // calculationSpec, pricebookIds,
    [k: string]: unknown     // listPriceCurrencies, order, ...
  },
  price?: ProductPrice,      // exactly one of flat_price |
                             //   tiered_price | composite_price
  pricebookId?: string,
  pricebookIds?: string[],
  listPriceCurrencies?: string[],
  crmProductId?: string | null,
  isActive?: boolean,
}

6. Draft lifecycle: publish, discard, status

Catalog products become a first-class draft layer alongside the existing spec drafts.

getDraftStatus

v3Admin.getDraftStatus reports catalogProducts: boolean (true if any state='draft' row exists). The client's HeaderActionBar adds catalogProducts to LAYER_ORDER so the badge shows up.

discardDrafts

When the user discards catalog products, the router deletes matching catalog_product draft rows. Other spec drafts continue to use the versioning store.

publishDrafts

The publish planner copies draft content (including pricebookId — a new field in CatalogPublishWrite) onto live rows in place, then deletes the consumed drafts. Tombstones become soft-deleted live rows.

7. Runtime compatibility (V3 → V2)

The pricing engine still consumes V2-shaped specs, and the assembled V3PricingSpecData shape is intentionally unchanged. Only the source of productSpecs moved.

assembleV3PricingSpec — before
if (!deps.catalogProductEnabled(orgId)) {
  // Flag OFF: return blob's inline
  // productSpecs verbatim
  return { ...body,
           productSpecs: body.productSpecs };
}
// Flag ON: source from table
return { ...body,
         productSpecs: await loadLive...() };
assembleV3PricingSpec — after
// No flag. productSpecs ALWAYS
// from catalog_product.
// Blob supplies non-product envelope.
if (mode === 'live') {
  return { ...body,
    productSpecs: await deps.loadLiveProductSpecs(orgId) };
}
return { ...body,
  productSpecs: await deps.loadPreviewProductSpecs(orgId, userId) };

For the V3 → V2 conversion path (createPreviewQuote, publish sync), product list-price datasheet entries are now derived from catalog_product.price via deriveV3Datasheet. The agent never authors product list-price datapoints anymore; that surface exists only as runtime glue.

8. What this PR does not change

9. Risks & things to watch

No fallback for product reads. The flag and the legacy-blob branch in assembleV3PricingSpec are deleted. An empty catalog_product table now genuinely means "empty catalog" — there is no path back to inline products. Any org that hasn't been migrated into catalog_product will see an empty product list on the agent path.
Parity test was tightened. m11IntAPricingSpecParity previously compared table-derived V2 against blob-derived V2 (both branches of the flag). It now compares table-derived V2 against a seed golden directly, which is a stronger assertion but still describe.skip — it's a headless DB-backed test.
Diff was truncated in the source. The trailing portions of publishDrafts.ts, discardDrafts.ts, getDraftStatus.ts, and getPricingSpec.ts weren't fully visible. The descriptions of those endpoints above are based on the PR summary and the visible portion of each diff — confirm during review that publish wiring for the new pricebookId field is consistent end-to-end.

Open questions for the reviewer