> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Constraints & Negotiation

> Hard budget / inventory / frequency caps, plus shadow-mode agent negotiation — rank under real-world constraints, propose bounded tweaks safely.

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

<Note>
  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](/decisioning/ranking-lagrangian) pass is enabled.
</Note>

### Example offer with constraints

```json theme={null}
{
  "name": "Gold Card Q2 Promo",
  "priority": 80,
  "budget": {
    "dailyCapCents": 50000,
    "lifetimeCapCents": 5000000
  },
  "inventory": {
    "totalStock": 1000,
    "remainingStock": 1000
  },
  "frequencyCaps": {
    "perCustomer": { "daily": 1, "weekly": 3, "monthly": 6 }
  }
}
```

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](/decisioning/ranking-lagrangian). 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](/decisioning/contact-policies), 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.

<Info>
  **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](/decisioning/negotiation-apply-mode).
</Info>

### 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

```json theme={null}
{
  "discount":        { "minPct": 0, "maxPct": 15 },
  "term":            { "minMonths": 6, "maxMonths": 24 },
  "priceFloorCents": 1000,
  "allowedCurrencies": ["USD", "EUR"],
  "bundleableAddons":  ["free-shipping", "extended-warranty"],
  "maxProposals":      1
}
```

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

### Calling the agent

```bash theme={null}
POST /api/v1/decisions/{decisionTraceId}/negotiate
{ "offerId": "...", "mode": "shadow" }
```

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 `priceFloorCents` → `price_below_floor`
* Use a currency not in `allowedCurrencies` → `currency_not_allowed`
* Bundle an add-on not in `bundleableAddons` → `addon_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](/decisioning/negotiation-apply-mode) for the full gate list, response shape, and audit trail.
