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.
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.
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.
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.
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)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.
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
- Sub-agent tools wrote
pricingSpec.productSpecs[id]as drafts of the whole pricingSpec blob. - Product list prices written as
datasheetdatapoints with aproductIdequality predicate. assembleV3PricingSpecbranched oncatalogProductEnabled(orgId): flag-off returned the legacy blob verbatim.- Tool surface:
createProduct,updateProductPrice,setProductPriceTiers,bulkWriteProducts, etc.
- Sub-agent tools stage
catalog_productdraft rows; preview is draft-over-live with tombstones for deletes. - Product prices live on
catalog_product.price(one offlat_price/tiered_price/composite_price). assembleV3PricingSpecalways readsproductSpecsfrom the table; the blob supplies only the non-product envelope.- Tool surface:
stageCreateCatalogProduct,stageUpdateCatalogProduct,stageUpdateCatalogProductPrice,stageDeleteCatalogProduct,bulkStageCatalogProducts, plus readslistCatalogProductsForAgent/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.
createCatalogProductTableTools + file-read shared tools. Legacy product tools (createProductCatalogTools, bulkWriteProducts) are no longer injected.skuId; staged deletes are filtered out. Field projection (fields: ["skuId","displayName"]) keeps token cost down.state='draft' row keyed by (orgId, skuId). Deletes are draft rows with isDeleted=true.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:
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.
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
| Tool | Purpose | Notes |
|---|---|---|
listCatalogProductsForAgent | List/preview with filters + field projection | Fetch skuId + displayName first; pull price / attributes on demand |
getCatalogProductForAgent | One product by skuId | Same projection contract |
listPricebooksForAgent | Active org pricebooks | Reads Pricebook table directly |
listCatalogDraftChangesForAgent | Staged catalog draft inventory | Classifies each row as create / update / delete vs live |
stageCreateCatalogProduct | Single create | Rejects unknown attributes; requires name |
stageUpdateCatalogProduct | Single patch | Carries identity + pricebook forward |
stageUpdateCatalogProductPrice | Price-only edit | Delegates to stageUpdateCatalogProduct internally |
stageDeleteCatalogProduct | Tombstone draft | Writes isDeleted=true, doesn't physically delete |
bulkStageCatalogProducts | JSON bulk upsert/create/update | Supports 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.
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.
When the user discards catalog products, the router deletes matching catalog_product draft rows. Other spec drafts continue to use the versioning store.
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.
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...() };
// 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
- Approval routing rules, flow spec, attribute schema, and non-product pricing config still live in the existing versioned blobs.
- Product Catalog page UI behavior is unchanged except that publish/discard now sees table-backed product drafts.
- The V2 pricing engine input shape — and therefore quote math — is byte-identical (asserted by
m11IntAPricingSpecParity.test.ts, which dropped its flag-on/flag-off split and now compares table-sourced V2 to the seed golden). - Public tRPC endpoints for product CRUD keep their schemas; only the implementation moved to
upsertDraftProduct. pricingSpec.productSpecsin the blob is no longer read or written by the agent path, but the field still exists for legacy storage compatibility.
9. Risks & things to watch
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.
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.
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
- Is there a separate migration story for orgs whose product data still lives only in
pricingSpec.productSpecs? The RFC presumably covers it; worth confirming the rollout order with the base branch's work. - Several legacy product tools (
setProductPriceTiers,bulkWriteProductson the legacy path) are no longer reachable from the sub-agent. Are they still referenced from anywhere else, or can the files be deleted in a follow-up? - The orchestrator now lists both
listProductsSummaryandlistCatalogProductsForAgentin its read-tool set. Worth a pass to make sure prompts don't push the model to call both redundantly.