V3 Approval Rules: Grouping & Draft Status
Server derives Product Discount / Terms / Custom buckets and per-rule draft state on read; writes stay generic.
product_discount / terms / custom) and per-rule draft status derived on read from v3Admin.getApprovalRules.
updateApprovalRules({ edges }) is unchanged. No new setup mutations. Grouping is a pure derivation on fetch.
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.
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.
{
workflowSpec,
isDraft,
versionInfo
}
{
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
draft workflowSpec
published workflowSpec
pricingFlowSpec → term catalog
diff draft vs. published
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.
| Condition | Status | Effect |
|---|---|---|
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 |
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.
Trigger: product. Every route must be a single block with exactly two conditions:
- An
equalson a specific SKU identity key (productId,productName,skuId,skuName). - A
gt/gte/equalsondiscountPercent.
All routes must share the same SKU identity. Category, product-line, family, and "extra logic" rules fall to custom.
Trigger: term with a termId present in the term catalog. Each route is a single condition:
equalsa known option value, orina set of known option values (deduped).
Runtime key aliases are honored: billingFrequency → quoteBillingFrequency, subscriptionTerms → subscriptionTerm. Repeated identical routes with different chainOrder are allowed (chained approvers).
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
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.
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 },
]
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 },
]
intrigger: { type: 'term',
termId: 'paymentTerms' }
conditions = [{
type: 'in', entity: 'quote',
key: 'paymentTerms',
values: ['Net 30', 'Net 60']
}]
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.
RuleTypeTabs component with three tabs (Product Discount / Terms / Custom), each showing a count pill. The tab state is local UI (useState).
ruleDraftStatuses[edge.id] !== 'published'. draft_deleted rows: strikethrough text, no edit button, no click-to-edit.
activeGraphEdges; only the table shows them.
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
- Write API is unchanged.
updateApprovalRules({ edges })still takes generic edges. NosetupProductDiscountRule, nosetupTermsRule. - No new persisted state.
ruleDraftStatusesandapprovalRulesByTypeare computed on every fetch. Cache invalidation onv3Admin.getApprovalRulesafter mutation is the only client contract. - No schema migration. Zero Prisma changes.
- No changes to
EdgeEditorSheet. The editor still edits raw edges; the tabs are only a display layer. - Legacy pricing flow (Dealops 1) is unaffected. The classifier only reads
pricingFlowSpecto build the term catalog.
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.
published.
in multi-option, chained-approver routes, runtime key aliases, unknown options, extra term logic, and reclassifying deleted-draft tombstones.
tsc --noEmit. No new runtime deps.
9. Risks & open questions
reason field on ruleTypeById is the only feedback channel today; there is no UI surface for it in this PR.
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).
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.