V3 Approval Rules: Grouping & Draft Status

Server derives Product Discount / Terms / Custom buckets and per-rule draft state on read; writes stay generic.

PR dealops#6210 Author @yijunz166 Branch codex/v3-approval-rule-tabs Files 4 +/- +1169 / -82 Area Dealops 2 → V3 Approvals

What it adds
Rule classification (product_discount / terms / custom) and per-rule draft status derived on read from v3Admin.getApprovalRules.
What it changes
The V3 Approval Rules page now renders three tabs, tombstones for deleted-draft rules, and status badges. The response payload grows accordingly.
What it preserves
Writes stay generic: updateApprovalRules({ edges }) is unchanged. No new setup mutations. Grouping is a pure derivation on fetch.
Product Discount
Terms
Custom
Draft added
Draft modified
Draft deleted

1. Why this exists

The V3 Approval Rules page previously showed a flat list of workflowSpec.edges. As rule counts grow, admins need to answer "what kind of rule is this?" and "what did the draft change?" without opening each edge. This PR keeps the write model unchanged and pushes both concerns into the read endpoint.

Problem A · No grouping
All rules render in one table. There's no way to scan just discount rules or just term-driven rules.
Problem B · Draft state opaque
When a draft workflow exists, the UI can't tell which specific rules were added, modified, or deleted vs. the published spec.
Design constraint
Flow Config agent authoring is disabled and loadForPreview(flowSpec) can mutate drafts on read. Use legacy pricingFlowSpec as the term catalog source.

2. Shape of the new response

Before this PR, getApprovalRules returned only workflowSpec, isDraft, and versionInfo. It now adds four fields derived from the same inputs.

Before
{
  workflowSpec,
  isDraft,
  versionInfo
}
After
{
  workflowSpec,
  isDraft,
  versionInfo,

  // NEW: per-rule draft state
  ruleDraftStatuses,   // Record<id, status>
  deletedDraftRules,   // tombstones

  // NEW: classification
  ruleTypeById,        // Record<id, {type, reason?}>
  approvalRulesByType  // {productDiscount, terms, custom}
}

Data flow on read

loadForPreview
draft workflowSpec
loadLatest
published workflowSpec
SpecService
pricingFlowSpec → term catalog
buildApprovalRuleDraftState
diff draft vs. published
+
buildApprovalRuleTypeState
classify each edge

3. Draft status derivation

Draft status is computed by comparing the draft edges[] to the published edges[] using a stable JSON canonicalization. There is no persisted status column on rules — it's always derived.

ConditionStatusEffect
Edge ID present in both, stableJson matches published No badge in UI
Edge ID present in both, JSON differs draft_modified Amber badge; still editable
Edge ID in draft only draft_added Green badge; still editable
Edge ID in published only (missing from draft) draft_deleted Returned as a tombstone in deletedDraftRules; row rendered with strikethrough, edit button hidden
Why tombstones exist: a deleted rule is absent from the draft workflowSpec.edges. To let the UI show "this rule will be deleted on publish," the endpoint re-attaches the published edge as a separate deletedDraftRules array. The client concatenates it into the table view.

4. Rule classification

Each edge is classified into exactly one bucket. The classifier is deliberately conservative — anything that doesn't match a narrow, safe shape falls through to custom. A reason string explains why.

product_discount

Trigger: product. Every route must be a single block with exactly two conditions:

  1. An equals on a specific SKU identity key (productId, productName, skuId, skuName).
  2. A gt/gte/equals on discountPercent.

All routes must share the same SKU identity. Category, product-line, family, and "extra logic" rules fall to custom.

terms

Trigger: term with a termId present in the term catalog. Each route is a single condition:

  • equals a known option value, or
  • in a set of known option values (deduped).

Runtime key aliases are honored: billingFrequencyquoteBillingFrequency, subscriptionTermssubscriptionTerm. Repeated identical routes with different chainOrder are allowed (chained approvers).

custom

Everything else. The classifier attaches a reason:

  • Product rule with extra conditions or mixed SKU identity → "…not exactly one SKU identity condition plus one discountPercent condition per route."
  • Term rule with unknown option, extra conditions, or unconfigured termId"…not exactly one configured select-term option mapped to one destination per route."
  • Any other trigger → "Rule does not match the product discount or terms patterns."

Term catalog source

The term catalog is built from the legacy pricingFlowSpec via SpecService.getPricingFlowSpec() and convertPricingFlowSpec(), walking calculator.dealTerms, calculator.terms, and each preSteps[].terms. The PR explicitly rejects using loadForPreview(flowSpec) here because that path can mutate/rebase drafts as a side effect of reading — unsafe from a plain fetch.

5. Classification examples

These are drawn from the test file (getApprovalRules.test.ts) and show the exact shapes that qualify or disqualify a rule.

product_discount — qualifies
trigger: { type: 'product' }
routes[0].blocks[0].conditions = [
  { type: 'equals', entity: 'sku',
    key: 'productName',
    value: 'Enterprise SKU' },
  { type: 'gte', entity: 'sku',
    key: 'discountPercent',
    value: 20 },
]
custom — same rule + extra condition
routes[0].blocks[0].conditions = [
  { type: 'equals', entity: 'sku',
    key: 'productName', value: '...' },
  { type: 'gte', entity: 'sku',
    key: 'discountPercent', value: 20 },
  { type: 'between', entity: 'sku',
    key: 'volume', min: 100, max: 500 },
]
terms — multi-option in
trigger: { type: 'term',
           termId: 'paymentTerms' }
conditions = [{
  type: 'in', entity: 'quote',
  key: 'paymentTerms',
  values: ['Net 30', 'Net 60']
}]
terms — runtime key alias
trigger: { type: 'term',
           termId: 'billingFrequency' }
conditions = [{
  type: 'equals', entity: 'quote',
  key: 'quoteBillingFrequency', // alias
  value: 'monthly'
}]

6. Client changes

Two files touch the UI. The bigger change is ApprovalRulesView.tsx: a rule-type tab bar sits above the existing Graph/List toggle, and the rules table now renders draft badges and disables interaction on tombstoned rows.

Tab bar
New RuleTypeTabs component with three tabs (Product Discount / Terms / Custom), each showing a count pill. The tab state is local UI (useState).
Table row states
Rows get a badge when ruleDraftStatuses[edge.id] !== 'published'. draft_deleted rows: strikethrough text, no edit button, no click-to-edit.
Graph view filter
Deleted-draft tombstones are excluded from the graph via activeGraphEdges; only the table shows them.
Fallback shape
If approvalRulesByType is missing (older server), the client falls back to putting everything into custom. Belt-and-braces during rollout.

Predicted approval indicators

A one-line change in usePredictedApprovalIndicators.ts propagates isPreview: true into the predicted-approval query when the resolved quote is in preview mode. This lets preview approval evaluation use the draft workflow's rules rather than the published set.

...(resolvedQuote.input.isPreview && { isPreview: true }),

7. What it doesn't change

8. Testing

A new 430-line test file exercises both derivation functions in isolation. The two builders are exported specifically so they can be unit-tested without spinning up a full tRPC context.

Draft state
Covers published / added / modified / deleted transitions, and the no-draft case where everything collapses to published.
Classification
Nine cases: qualifying product discount, product discount with extra logic, select-term options, in multi-option, chained-approver routes, runtime key aliases, unknown options, extra term logic, and reclassifying deleted-draft tombstones.
Type checks
Server and client both run tsc --noEmit. No new runtime deps.

9. Risks & open questions

Rollout risk — first-launch classifier scope. The classifier is intentionally narrow. Rules that admins intend as "product discount" but which include volume tiers, product families, or category filters will land in Custom. The reason field on ruleTypeById is the only feedback channel today; there is no UI surface for it in this PR.
Term catalog coupling. Because the term catalog is built from legacy pricingFlowSpec, an org that migrates fully to Flow Config authoring could lose term classification until the catalog source is switched. The PR description acknowledges this and calls it a first-launch trade-off (Flow Config agent authoring is disabled, and loadForPreview(flowSpec) is unsafe from a read).
Silent fallback on flow-spec read failure. If SpecService.getPricingFlowSpec throws, termSourceSpec becomes null, the term catalog is empty, and all term rules get classified as Custom. Logged as a warning; no user-visible error. Worth watching in telemetry after ship.