AI Quoting: ABI semantics, 1k-unit tiers, and Transfer guardrails

Locks down three classes of model error in the Dealops 2 AI quoting pipeline — ABI shorthand, malformed price tiers, and Transfer rail/risk/mode bundles — at both the prompt and deterministic-guardrail layers.

Author @yijunz166 PR dealops#6184 Branch yijun/ai-quoting-transfer-tier-guardrails Files 16 +/- +1477 / -347 Area dealops2/aiQuoting

What it adds

Three new families of hard rules — ABI scope, strict 1,000-unit tier boundaries, and Transfer bundle / pricing-mode constraints — enforced in both the LLM prompts and deterministic Stage 4/5 code.

What it changes

Tier validation moves from "non-overlap + max-coverage" to strict contiguous 1k bands. Transfer bps SKUs now map to quotePricePercent with an avgTransactionSize term instead of flat pricing.

What it preserves

Pipeline shape, orchestrator wiring, L1/list-price guardrails, ramp logic, and the existing validate-and-correct one-shot pattern all stay intact. No schema migration.

ABI rule
Tier rule
Transfer rule
Helpers / shared
RFC / prompts

1. Why this exists

The AI quoting agent (Dealops 2, apps/server/src/dealops2/aiQuoting/pipeline) was producing three recurring classes of bad quotes that the existing guardrails couldn't catch:

ABI confusion

When a buyer said "ABI", the model sometimes resolved it to the Auth & Identity bundle SKU instead of the literal three products Auth, Balance, Identity. Same TLA, different commercial outcome.

Sloppy tier boundaries

The agent emitted tiers like 0–1000 / 1001–10M or arbitrary thresholds (500, 2500). Non-overlap alone didn't enforce the actual Plaid commercial convention: clean 1,000-unit bands.

Transfer free-for-all

Transfer base SKUs were shipped without the four companion event SKUs, or with both bps and Fixed variants for the same rail/risk combo, or with a pricing mode the average transaction size didn't support.

The fix is symmetric: add the rules to the three prompts (quote_generation.v1.md, quote_validation.v1.md, quote_validate_correct.v1.md) so the model knows them, and add deterministic checks in validateAiRecommendationAgainstRules.ts and generateV2Quote.ts so the model can't slip past them.

2. The three new rule families

2.1 ABI means exactly three products

Before

Buyer says "ABI". Model could pick the Auth & Identity bundle SKU and silently drop Balance.

QUOTE Auth & Identity bundle

After

ABI in opportunity context → Auth, Balance, Identity are all required. Missing any of the three triggers ABI_PRODUCT_MISSING.

QUOTE Auth + Balance + Identity

Detection lives in aiQuoteRuleHelpers.ts:

export function contextMentionsAbiProducts(text: string): boolean {
  const lower = text.toLowerCase();
  return (
    /\babi\b/.test(lower) ||
    (/\bauth\b/.test(lower) &&
      /\bbalance\b/.test(lower) &&
      /\bidentity\b/.test(lower))
  );
}

The validator walks ABI_PRODUCT_NAMES = ['Auth', 'Balance', 'Identity'], looks each up in the catalog, and emits a hard issue per missing product. The fixture transcripts in fetchOpportunityContext.ts were updated so the demo opp explicitly says "ABI scope: Auth, Balance, and Identity only" to exercise the rule end-to-end.

2.2 Price tiers must use strict 1,000-unit bands

The old check in generateV2Quote.ts only verified two things: tiers don't overlap, and the highest tier covers up to a 1M sentinel. Anything in between — like 0–999 / 1001–10M — slipped through.

The new rule, in validateStrictPriceTiers, demands:

Shape

Between 2 and 10 tiers per product. Single-tier "tiers" should be a flat price; more than 10 is operationally unmanageable.

Boundaries

First tier starts at 0. Each subsequent tier starts at i * 1000. Previous tier's toUnits must equal fromUnits − 1.

Open-ended tail

Final tier's toUnits must reach at least the TIER_MAX_COVERAGE_SENTINEL = 1_000_000 mark — no finite cap on the top tier.

Tier shapeOld resultNew result
0–999, 1000–1_000_000✅ passPASS
0–1000, 1000–1_000_000 (overlap at 1000)❌ "overlap""must end at 999 units"
0–499, 500–2000 (wrong boundary)❌ "overlap""must start at 1000 units"
0–999, 1000–1999 (finite cap)❌ "max-coverage""final tier must cover open-ended usage"
11 tiers✅ pass"tier count 11 exceeds maximum 10"

Both isTierShapeValid (Stage 5 mapping) and hasValidPriceTiers (Stage 4 validator) now delegate to the shared helper, so the rule is enforced exactly once in code.

2.3 Transfer rail × risk × pricing mode

Transfer SKUs have a structured name that encodes three dimensions. The new parsers in aiQuoteRuleHelpers.ts split them out and the validator reasons about the bundle as a whole.

// Matches: "Transfer (Standard ACH) - Vanilla Originators - bps"
/^Transfer \((Standard ACH|Same-day ACH)\) - (Vanilla Originators|High Risk Customers) - (bps|Fixed)$/i

// Matches: "Transfer (Incoming Wire) - Vanilla Originators"
/^Transfer \((All other returns|Incoming Wire|NOC|Unauthorized returns)\) - (Vanilla Originators|High Risk Customers)$/i

Given a parsed base product with a comboKey of rail::riskTier, four checks run:

Companions required

For each base SKU, the same risk tier must include all four event SKUs: All other returns, Incoming Wire, NOC, Unauthorized returns. Missing any → TRANSFER_COMPANION_MISSING.

One mode per combo

If the quote contains both bps and Fixed for the same rail+risk, that's TRANSFER_MODE_CONFLICT. Exactly one mode is allowed.

Mode follows ATS

Average transaction size determines the mode (see band below). Wrong mode for the ATS → TRANSFER_PRICING_MODE_MISMATCH.

ATS is blocking

If ATS can't be extracted from any call/opp text, the quote doesn't get to guess — TRANSFER_AVG_TRANSACTION_SIZE_MISSING blocks generation.

The pricing-mode band:

Fixed
bps   ($100 ≤ ATS ≤ $600)
Fixed

< $100   |   $100  ←  bps band  →  $600   |   > $600

ATS extraction is regex-based against the assembled context text (opp name, primary product, use case, secondary product, technical use cases, plus every Gong call's subject/brief/key points/next steps/full description). Three patterns catch variants like "average transaction size is $250", "txn size: ~250 avg", and "$250 avg ticket".

3. Where the rules fire

Each rule is enforced at three layers — prompt, LLM validator, deterministic code — so a regression at one layer doesn't ship a bad quote.

Rule Prompt LLM validate/correct Deterministic guardrail
ABI = Auth + Balance + Identity quote_generation.v1.md §ABI Shorthand Rule 8 in both validation prompts ABI_PRODUCT_MISSING
1k-unit tier bands, ≤10 tiers, open-ended tail quote_generation.v1.md §Tiered Pricing Rule 9 in both validation prompts PRICE_TIER_SHAPE_INVALID (Stage 4) + isTierShapeValid (Stage 5)
Transfer companions required quote_generation.v1.md §Transfer Rule 10 TRANSFER_COMPANION_MISSING
Transfer pricing mode bands quote_generation.v1.md §Transfer Rule 11 TRANSFER_PRICING_MODE_MISMATCH, TRANSFER_MODE_CONFLICT, TRANSFER_AVG_TRANSACTION_SIZE_MISSING

4. Transfer bps → percent pricing wiring

The other half of the Transfer story isn't validation — it's mapping. Previously every product became a quotePriceFlat. bps Transfer SKUs need quotePricePercent with floor/ceiling caps, and an avgTransactionSize term carried alongside.

The mapping path now branches on a new catalog field calculationSpec.bpsPercentPricing:

function buildProductPricingInput(params) {
  // ...
  if (isBpsPercentCatalogProduct(productEntry)) {
    return {
      quotePricePercent: buildQuotePricePercent({ /* rate + floor + ceiling */ }),
      ...(terms ? { terms } : {}),
    };
  }
  return {
    quotePriceFlat: buildQuotePrice(/* ... */),
    ...(terms ? { terms } : {}),
  };
}

The resulting product payload for a Transfer bps SKU looks like:

{
  quotePricePercent: {
    type: 'percentWithLimits',
    value:        { type: 'percent',  value: 0.1 },
    perTxnFloor:  { type: 'currency', value: 0.01, currency: 'USD' },
    perTxnCeiling:{ type: 'currency', value: 2,    currency: 'USD' },
  },
  terms: [{
    type: 'termInput',
    id: 'avgTransactionSize',
    variableId: 'avgTransactionSize',
    userValue: { type: 'currency', value: 250, currency: 'USD' },
  }],
}

ProductInput in generateV2Quote.ts gained two new optional fields (quotePricePercent, terms) and finalizeAiQuote.ts propagates them through quote creation. ProductCatalogEntry in generateAiQuote.ts gained a structured calculationSpec carrying requiresTransactionSize, bpsPercentPricing, bpsPerTxnFloor, bpsPerTxnCeiling, and a transactionSizeBand.

5. New issue codes and what they tell the model

Each new code maps to a human-readable fallback in generateValidatedAiRecommendation.ts and a "feedback" string the corrector sees on the next pass:

CodeFires whenCorrector feedback
ABI_PRODUCT_MISSINGContext mentions ABI but Auth/Balance/Identity missing"Add X; ABI maps exactly to Auth, Balance, and Identity."
PRICE_TIER_SHAPE_INVALIDTiers fail 1k-band / 2–10 / open-tail checkRegenerate tiers using "0-999, 1000-1999, 2000-10000000"
TRANSFER_AVG_TRANSACTION_SIZE_MISSINGTransfer base in quote, no ATS in any context text"Do not guess the Transfer pricing mode."
TRANSFER_PRICING_MODE_MISMATCHMode picked doesn't match the ATS bandReplace with the expected variant (bps or Fixed)
TRANSFER_MODE_CONFLICTBoth bps and Fixed for same rail/riskSelect exactly one mode for this combo
TRANSFER_COMPANION_MISSINGBase SKU missing one of the four event SKUsAdd the named companion in the same risk tier

6. New shared module: aiQuoteRuleHelpers.ts

The PR consolidates rule logic that was previously duplicated between Stage 4 (validation) and Stage 5 (V2 mapping) into one file. Both stages now import from it.

Constants

PRICE_TIER_VOLUME_INCREMENT = 1000, MAX_PRICE_TIERS_PER_PRODUCT = 10, TIER_MAX_COVERAGE_SENTINEL = 1_000_000, ABI_PRODUCT_NAMES, TRANSFER_COMPANION_EVENTS.

Tier validator

validateStrictPriceTiers(tiers) — single source of truth, returns null on success or a human-readable reason. Used by both Stage 4 and Stage 5.

Transfer parsers

parseTransferBaseProductName / parseTransferCompanionProductName — regex-based decomposition into rail / risk / mode / event.

ATS extraction

extractAverageTransactionSizeFromText — three regex variants over the full opp+calls text, returns first positive match or null.

Context flattener

opportunityContextToRuleText — moved out of the validator. Builds the single text blob ABI / ATS detection runs against.

Catalog probes

isBpsPercentCatalogProduct, requiresAverageTransactionSize, findTransferCompanionByEvent — used by the V2 mapper to decide percent vs flat and to attach terms.

7. Test coverage added

Two test files grow significantly; one is rewritten to match the new tier rules.

generateV2Quote.test.ts

±10/-10. Existing tier-shape and tier-semantic tests retargeted: overlap/coverage messages replaced with the new "must end at 999 units" / "must start at 1000 units" / "final tier must cover open-ended usage" assertions.

generateV2Quote.tiered.test.ts

+109. New E2E case: a Transfer Standard ACH bps SKU with ATS=$250 in the opportunity use-case text maps to quotePricePercent with the right floor/ceiling and emits an avgTransactionSize term of $250 USD.

validateAiRecommendationAgainstRules.test.ts

+361. Seven new cases: ABI missing/satisfied, tier 1k-boundary rejection, >10 tiers rejection, Transfer ATS-missing block, wrong-mode rejection, bps+Fixed conflict, missing-companion rejection, and a clean Transfer bps pass with all companions + ATS.

8. What it doesn't change

9. Risks and open questions

ATS regex coverage. The three patterns in extractAverageTransactionSizeFromText cover the obvious phrasings ("average transaction size is $250", "$250 avg ticket"), but unusual call-note phrasings could miss and trigger TRANSFER_AVG_TRANSACTION_SIZE_MISSING on a quote that should have proceeded. The failure mode is conservative (block, don't guess), but it will show up as a "blocked quote" in production if the regexes miss real evidence.
Companion-SKU naming brittleness. Transfer rules pattern-match on exact catalog product names like "Transfer (Incoming Wire) - Vanilla Originators". If the catalog ever re-spells these (e.g. "Incoming Wires"), the parsers silently stop matching and the bundle rule stops firing. No fallback by spec-id.
Rollback is clean. No schema change, no data migration. Reverting the PR restores the prior tier behavior and turns the new validation codes back into no-ops. Existing quotes are unaffected.