Skip to main content
KaireonAI’s ranking runs in two stages: a hard-constraint filter that drops any offer that can’t be served right now, then the weighted composite score (PRIE / multi-objective) that ranks the survivors. This page covers both stages plus the optional agent-in-the-loop negotiation that can propose bounded tweaks after a winner is selected.

Hard constraints — what gets dropped before ranking

Three constraint types filter the candidate set before scoring:

Budget caps

  • Offer.budget.dailyCapCents — daily spend ceiling (auto-resets at day boundary)
  • Offer.budget.lifetimeCapCents — lifetime spend ceiling (never resets)
  • Running totals (currentDailySpentCents, currentLifetimeSpentCents) update automatically on positive outcomes via consumeBudget in the /respond flow
  • lastDailyResetDate bookkeeping is handled by the platform

Inventory counters

  • Offer.inventory.totalStock — starting stock
  • Offer.inventory.remainingStock — decrements on positive outcomes; offer drops from candidate set at 0
  • Unset = untracked (no inventory enforcement)

Frequency caps

  • Offer.frequencyCaps.perCustomer.{daily,weekly,monthly} — max impressions per customer per window
  • Counts are read from the interaction history table with a single batched query per window (not N+1 across candidate offers)
  • Fail-open: if the counter query throws, the offer is allowed through and the failure is logged
These three hard constraints are enforced by checkConstraintsBatch in the batch / segment execution engine (src/lib/ranking/constraints.ts), which the Runs module uses to score a whole segment. Running totals are consumed on positive outcomes in /api/v1/respond via consumeBudget / consumeInventory. The realtime /api/v1/recommend pipeline caps per-customer frequency through contact policies (frequency_cap, customer_total_cap) rather than Offer.frequencyCaps, and applies budget / inventory pressure only when the optional Lagrangian ranking pass is enabled.

Example offer with constraints

When a batch/segment run evaluates this offer, it is dropped from candidates if:
  • Today’s spend has hit $500.00
  • Lifetime spend has hit $50,000.00
  • Remaining stock is 0
  • This customer has already seen this offer today / 3 times this week / 6 times this month

Scope of the hard-constraint filter

The budget / inventory / frequency filter above is a hard binary filter — it drops offers, it does not soft-optimize. A few related capabilities live in other subsystems rather than in this filter:
  • Lagrangian soft-optimization across multiple constraints is available as an optional, default-off ranking pass — see Lagrangian Ranking. The Offer.budget / Offer.inventory filter itself always stays a hard drop.
  • Cross-offer / customer-total caps (“at most N offers total per customer per window across all offers”) are enforced by the customer_total_cap contact policy, not by Offer.frequencyCaps (which is strictly per-offer).
  • Budget alerting / refill workflows are not built in — update dailyCapCents / lifetimeCapCents manually.
  • Seasonality / time-based modulation of the constraint thresholds is not supported; the filter reads current state at decision time. (Time-of-day suppression is available via the time_window contact policy.)

Agent-in-the-loop negotiation (shadow-mode)

After ranking selects a winner, some offers support a negotiation pass where an LLM agent proposes bounded tweaks — a small discount, a different term length, an add-on — within hard guardrails.
This endpoint is shadow-mode only. Every proposal is validated against the guardrails in code (not just the prompt), and recorded in the audit log. This endpoint never applies a proposal to a live decision. A separate, gated apply-mode wire can promote an accepted negotiation into the realtime /api/v1/recommend response — it is default-off and fails closed until an operator explicitly clears every gate. See Negotiation apply-mode.

Enabling negotiation

Three gates must pass, all fail-closed:
  1. Tenant opt-in: the tenant setting aiAnalyzerSettings.negotiationEnabled must be true. Set it with an admin-only PUT /api/v1/ai/analyzer-settings request that includes { "negotiationEnabled": true } in the body; the write merges the flag into aiAnalyzerSettings without disturbing the analyzer-config sections or the runtime-managed ranking state, and the current value is returned by GET /api/v1/ai/analyzer-settings. The negotiate route reads it from TenantSettings and fails closed when it is absent or false.
  2. Per-offer flag: Offer.negotiable = true
  3. Per-offer guardrails: Offer.negotiationGuardrails with explicit bounds.

Guardrail schema

Anything missing means “not permitted” — if discount is absent, the agent MUST NOT propose a discount.

Calling the agent

Response shape: a negotiation session with proposals: ValidatedProposal[]. Each proposal has:
  • valid: boolean
  • proposal: { rationale, discountPct?, termMonths?, bundleAddons?, finalPriceCents?, currency? } | null
  • violations: GuardrailViolation[] — a typed list from 11 possible violation codes
Every session writes an audit log entry with action="negotiate_shadow" and entityType="decision_trace" — so DPO and audit teams can see exactly what the agent considered, even when nothing was applied.

What the agent cannot do

Enforced in code by the negotiation guardrails module:
  • Propose a discount outside [discount.minPct, discount.maxPct]discount_below_floor / discount_above_ceiling
  • Propose a discount when none is permitted → discount_not_permitted
  • Propose a term length when none is permitted → term_not_permitted
  • Propose a term length outside the band → term_below_floor / term_above_ceiling
  • Propose a final price below priceFloorCentsprice_below_floor
  • Use a currency not in allowedCurrenciescurrency_not_allowed
  • Bundle an add-on not in bundleableAddonsaddon_not_permitted
  • Omit the rationale field → rationale_missing
  • Exceed maxProposals in a single session → trailing proposals marked schema_invalid
A proposal with any violation is returned with valid: false and proposal: null. There is no code path that stores or surfaces a rejected proposal — the validation happens before any mutation.

Rate limits + cost

  • Negotiation endpoint: 10 requests / minute / tenant (LLM cost control)
  • No negotiation call ever runs on the /recommend hot path
  • Configured AI provider is used (Claude / GPT / Gemini / Bedrock / Ollama / LM Studio — whatever is set in the tenant’s AI configuration)

Promotion path to apply-mode

Apply-mode has shipped, but it is default-off and fails closed on every gate. Before an accepted negotiation is promoted into a live decision, all of these must hold (enforced in src/lib/negotiation/apply-mode.ts):
  1. applyModeEnabled flag set on the tenant
  2. regulatorReviewCleared set (intended for after an offline eval-harness run shows zero guardrail violations)
  3. Neither the tenant nor global kill switch is tripped, and the rolling validation-failure rate is under autoKillThreshold
  4. The offer is negotiable with valid guardrails, and the per-tenant daily apply cap is not exhausted
See Negotiation apply-mode for the full gate list, response shape, and audit trail.