V3 product approvals now separate SKU identity from quote-line identity
This PR fixes product-triggered V3 approval rules for edited, cloned, ramped, and multi-period quote lines whose row ID no longer equals the catalog SKU ID.
V3 quote lines carry two product identities. The old bridge treated them as interchangeable, so UUID-backed lines could miss runtime discount data or place the approval pill on the wrong row.
productSpecId is now used for rule matching and spec metadata. Quote-line id is now used for pricingEngineSummary.byProduct runtime lookup.
Product discount approvals fire for the correct row, including later-period quote lines whose instance IDs are UUIDs.
productSpecId / SKU identityid / instance identitypricingEngineSummary.byProduct1. Why this exists
Catalog / SKU identity
productSpecId = "platform-enterprise"Used to answer: “Which product is this?” Approval rule predicates match against this value via productId == "...".
Quote-line instance identity
id = "bcc24cf1-2da3-4ed7-b7dd-c01b24f391ca"Used to answer: “Which specific row has calculated pricing?” Runtime discount fields are keyed by this quote-line ID.
id === productSpecId, so the identity conflation was hidden. Edited, cloned, ramped, or multi-period quotes can assign UUID row IDs to later lines, which made product discount rules read the wrong key.
- ×Rule matching needed the SKU, but runtime lookup also used the SKU.
- ×
byProduct[skuId]returnedundefinedwhen runtime was stored under the UUID quote-line ID. - ×The product discount predicate silently failed, or an approval could attach to the wrong table row.
2. What changes
apps/server/src/dealops3/approvals/contextBuilder.ts
core identity split
apps/server/src/dealops3/approvals/v2Bridge.ts
bridge + trigger id
apps/server/src/dealops3/__tests__/v3ApprovalProductIdentity.test.ts
new regression coverage
One variable, productId, tried to do both jobs.
const productId = qp.productSpecId ?? qp.id;
buildProductContext(
bundle,
baseContext,
productId,
isPreview,
);
That same value was used for spec lookup, rule matching, runtime lookup, and trigger row identity.
Two identities are carried explicitly.
const specId = qp.productSpecId ?? qp.id;
const instanceId = qp.id ?? specId;
buildProductContext(
bundle,
baseContext,
specId,
instanceId,
isPreview,
);
Each downstream operation now receives the identifier it actually expects.
Carries productSpecId and id.
specId becomes productId in approval context.
instanceId reads byProduct[lineId].
Product trigger emits the quote-line instance ID.
Pricing table can render the approval pill on the matching row.
| File | Changed behavior | Reviewer focus |
|---|---|---|
contextBuilder.ts |
buildProductContext signature changes from one product ID to specId + instanceId. |
Confirms flat.productId = specId happens even if product spec metadata is absent. |
v2Bridge.ts |
Passes specId for matching and instanceId for runtime; emits product trigger with instanceId. |
Confirms trigger identity matches the client’s quote-row ID mapping. |
v3ApprovalProductIdentity.test.ts |
Adds Mocha regression tests for UUID-backed product lines and no-fallback behavior. | Confirms the fix handles both fresh quotes and divergent IDs. |
3. How it works
buildProductContext now accepts specId and instanceId.
flat.productId = specIdsupports product rule predicates.v3Spec.productSpecs[specId]enriches product metadata when present.extractProductRuntimeContext(..., instanceId)reads discount runtime from the row key.
evaluateV3ForPrediction now derives both IDs per quote product.
specId = qp.productSpecId ?? qp.idinstanceId = qp.id ?? specId- Skip only when no
specIdexists.
The product trigger now sends id: instanceId.
That matters because the client maps product triggers to quote rows by line ID, and extractTriggerSlice can resolve line IDs to canonical product/period keys.
The tests explicitly reject a “try SKU, then try line ID” fallback. A fallback could read a discount from a different row that happens to be keyed by the SKU.
// Correct for divergent IDs:
byProduct[LINE_UUID] => { discountFlat: { type: "percent", value: 51 } }
// Incorrect fallback risk:
byProduct[SKU] => { discountFlat: { type: "percent", value: 99 } }
4. Verification coverage
- ✓Runtime is read by quote-line instance ID.
- ✓Fresh-quote control still works when SKU and line ID match.
- ✓No SKU-keyed fallback is used for a different line.
- ✓A real
productId + discountPercentrule fires for a UUID line. - ✓The same rule does not fire when runtime lives only under the wrong SKU key.
5. What it doesn’t change
- No approval rule schema changes: product rules still match on
productId. - No pricing engine summary shape changes: runtime remains under
pricingEngineSummary.byProduct. - No client rendering logic is changed in this PR.
- No Prisma migration, database column change, or tRPC route change is introduced.
- No “try both IDs” compatibility fallback is added; this is intentional to avoid cross-row attribution.
- Fresh quotes where
id === productSpecIdcontinue to work.
6. Risks / rollback / open questions
buildProductContext must now pass both identities with the same semantics. In this diff, the bridge call site is updated, and the test file exercises the new signature directly.
Revert the two approval code changes and the regression test if product-triggered V3 approvals regress. The rollback is code-only; there is no data migration to unwind.
None called out in the PR description. Reviewers should still scan for any hidden product-context call sites outside the shown diff.