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.

PR #6433 Issue DEA-7063 Author @mehulshinde Area apps/server · dealops3 State open Files 3 Diff +174 / -11

Problem

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.

Fix

productSpecId is now used for rule matching and spec metadata. Quote-line id is now used for pricingEngineSummary.byProduct runtime lookup.

Result

Product discount approvals fire for the correct row, including later-period quote lines whose instance IDs are UUIDs.

productSpecId / SKU identity
Quote-line id / instance identity
pricingEngineSummary.byProduct
V3 approval trigger

1. 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.

Bug shape: fresh quotes often had 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.
Failure mode before this PR

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
Before

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.

After

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.

Quote line

Carries productSpecId and id.

Rule identity

specId becomes productId in approval context.

Runtime

instanceId reads byProduct[lineId].

Trigger

Product trigger emits the quote-line instance ID.

UI row

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

Context builder

buildProductContext now accepts specId and instanceId.

  • flat.productId = specId supports product rule predicates.
  • v3Spec.productSpecs[specId] enriches product metadata when present.
  • extractProductRuntimeContext(..., instanceId) reads discount runtime from the row key.
Bridge

evaluateV3ForPrediction now derives both IDs per quote product.

  • specId = qp.productSpecId ?? qp.id
  • instanceId = qp.id ?? specId
  • Skip only when no specId exists.
Trigger output

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.

Important non-fallback decision

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

New regression file
Test frameworkMocha
New tests5
Coverage targetDEA-7063
Assertions added
  • 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 + discountPercent rule fires for a UUID line.
  • The same rule does not fire when runtime lives only under the wrong SKU key.
Verification status: the PR description says focused V3 approval regression coverage passes. The attached screenshot is not embedded here because this explainer is self-contained and offline-renderable.

5. What it doesn’t change

Preserved behavior

6. Risks / rollback / open questions

Primary review risk: any other caller of 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.
Rollback

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.

Open question

None called out in the PR description. Reviewers should still scan for any hidden product-context call sites outside the shown diff.