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

# Cross-Offer Constraints API

> CRUD API for cross-offer ranking constraints fed into the realtime Lagrangian solver on /recommend.

Cross-offer constraints let operators model rules that span multiple offers — channel quotas, portfolio spend caps, and category limits — in a single Lagrangian solve. The constraints are read on every `/recommend` call when `tenantSettings.aiAnalyzerSettings.ranking.lagrangianEnabled` is true.

<Info>
  Cross-offer constraints work through the Lagrangian solver, not the hard-constraint filter. They apply a continuous shadow-price penalty that softly rotates traffic rather than hard-dropping offers. See [Lagrangian Ranking](/decisioning/ranking-lagrangian) for background.
</Info>

## Base path

```
/api/v1/cross-offer-constraints
```

***

## List cross-offer constraints

```
GET /api/v1/cross-offer-constraints
```

Returns all cross-offer constraints for the current tenant, ordered by creation date (newest first).

### Query parameters

| Parameter | Type   | Description                                                                                           |
| --------- | ------ | ----------------------------------------------------------------------------------------------------- |
| `status`  | string | Filter by status. One of `active`, `inactive`, `archived`. Omit to return all statuses.               |
| `limit`   | number | Max items per page (default 50, max 100).                                                             |
| `cursor`  | string | Opaque keyset cursor for the next page. Use the `pagination.cursor` value from the previous response. |

### Response `200`

```json theme={null}
{
  "data": [
    {
      "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "tenantId": "b25341df-b709-495b-a17a-44257f8fe8f1",
      "name": "Email channel cap",
      "scope": "global",
      "scopeId": null,
      "ruleType": "channel_quota",
      "config": {
        "channels": ["email"],
        "cap": 3
      },
      "status": "active",
      "createdAt": "2026-06-08T10:00:00.000Z",
      "updatedAt": "2026-06-08T10:00:00.000Z"
    }
  ],
  "pagination": {
    "limit": 50,
    "cursor": null,
    "hasMore": false,
    "total": 1
  }
}
```

### Error codes

| Code  | Reason                     |
| ----- | -------------------------- |
| `401` | Missing tenant context.    |
| `403` | Invalid tenant identifier. |

***

## Create a cross-offer constraint

```
POST /api/v1/cross-offer-constraints
```

Creates a new cross-offer constraint. Returns `201` with the created row.

### Request body — shared fields

| Field      | Required | Type           | Description                                                                                                                                                          |
| ---------- | -------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`     | **Yes**  | string (1-255) | Unique constraint name within the tenant. HTML tags are stripped.                                                                                                    |
| `ruleType` | **Yes**  | string         | One of `channel_quota`, `portfolio_budget`, `category_cap`. Determines which `config` shape is expected.                                                             |
| `config`   | **Yes**  | object         | Rule-type-specific config. See shapes below.                                                                                                                         |
| `scope`    | No       | string         | One of `global`, `category`, `sub-category`, `channel`, `offer-set`. Default `global`.                                                                               |
| `scopeId`  | No       | string \| null | Scoping ID used by the batch solver to narrow which offers a constraint applies to. Not consumed by the realtime solver — see [Runtime paths](#runtime-paths) below. |
| `status`   | No       | string         | One of `active`, `inactive`, `archived`. Default `active`. Only `active` rows are loaded at solve time.                                                              |

### `config` shapes by `ruleType`

#### `channel_quota`

Caps the number of offers selected from the listed channels in a single solve.

```json theme={null}
{
  "ruleType": "channel_quota",
  "config": {
    "channels": ["email", "push"],
    "cap": 2
  }
}
```

| Field      | Type              | Description                                                                             |
| ---------- | ----------------- | --------------------------------------------------------------------------------------- |
| `channels` | string\[] (min 1) | Channel IDs to apply the cap to. Each offer's `channelId` is checked against this list. |
| `cap`      | number (>= 0)     | Maximum picks allowed across all listed channels.                                       |

Cost vector: `1` per pick of an offer whose `channelId` is in `channels`, `0` otherwise.

#### `portfolio_budget`

Caps total cost (summed `costPerAction`) across a set of offers.

```json theme={null}
{
  "ruleType": "portfolio_budget",
  "config": {
    "offerIds": ["off_premium_card", "off_gold_loan"],
    "cap": 500.00
  }
}
```

| Field      | Type              | Description                                                             |
| ---------- | ----------------- | ----------------------------------------------------------------------- |
| `offerIds` | string\[] (min 1) | Offer IDs whose spend counts toward the cap.                            |
| `cap`      | number (>= 0)     | Maximum total spend (in the same unit as `offer.budget.costPerAction`). |

Cost vector: the offer's `costPerAction` for offers in scope, `0` for all others.

<Warning>
  A `portfolio_budget` constraint only binds when each in-scope offer has `costPerAction > 0` at solve time. Offers with `costPerAction = 0` or missing contribute zero cost and do not reduce the budget, leaving the cap effectively unbounded for those offers.
</Warning>

#### `category_cap`

Caps the number of offers selected from the listed categories.

```json theme={null}
{
  "ruleType": "category_cap",
  "config": {
    "categories": ["Credit Cards", "Loans"],
    "cap": 2
  }
}
```

| Field        | Type              | Description                                                       |
| ------------ | ----------------- | ----------------------------------------------------------------- |
| `categories` | string\[] (min 1) | Category names to apply the cap to. Matching is case-insensitive. |
| `cap`        | number (>= 0)     | Maximum picks from the listed categories.                         |

Cost vector: `1` per pick of an offer whose `categoryName` matches any entry in `categories` (case-insensitive), `0` otherwise.

### Response `201`

Returns the created constraint row.

### Error codes

| Code  | Reason                                                                                     |
| ----- | ------------------------------------------------------------------------------------------ |
| `400` | Validation error — missing required field, invalid `ruleType`, or `config` shape mismatch. |
| `401` | Missing tenant context.                                                                    |
| `403` | Insufficient role (requires `editor` or `admin`).                                          |
| `409` | A constraint with the same `name` already exists for this tenant.                          |
| `413` | Request body exceeds the 2 MB limit.                                                       |
| `415` | `Content-Type` is not `application/json`.                                                  |

***

## Update a cross-offer constraint

```
PUT /api/v1/cross-offer-constraints
```

Updates an existing constraint. Only fields that are present in the request body are changed (sparse update).

### Request body

| Field      | Required | Type           | Description                                                                                 |
| ---------- | -------- | -------------- | ------------------------------------------------------------------------------------------- |
| `id`       | **Yes**  | string (UUID)  | The constraint ID to update.                                                                |
| `name`     | No       | string (1-255) | New name. Must be unique per tenant.                                                        |
| `scope`    | No       | string         | Updated scope.                                                                              |
| `scopeId`  | No       | string \| null | Updated scope ID. Sending `null` clears it.                                                 |
| `ruleType` | No       | string         | Updated rule type.                                                                          |
| `config`   | No       | object         | Updated config object. Full replacement — no deep merge.                                    |
| `status`   | No       | string         | Updated status. Set to `inactive` to take a constraint out of rotation without deleting it. |

### Response `200`

Returns the updated constraint row.

### Error codes

| Code  | Reason                                                               |
| ----- | -------------------------------------------------------------------- |
| `400` | Validation error (including a non-UUID `id`).                        |
| `401` | Missing tenant context.                                              |
| `403` | Insufficient role (requires `editor` or `admin`).                    |
| `404` | Constraint not found or does not belong to this tenant.              |
| `409` | Updated `name` collides with an existing constraint for this tenant. |

***

## Delete a cross-offer constraint

```
DELETE /api/v1/cross-offer-constraints?id={constraintId}
```

Hard-deletes a constraint by ID. There is no soft-delete — use `status: "archived"` to retain the row for audit purposes.

### Query parameters

| Parameter | Required | Description                       |
| --------- | -------- | --------------------------------- |
| `id`      | **Yes**  | UUID of the constraint to delete. |

### Response `200`

```json theme={null}
{ "success": true }
```

### Error codes

| Code  | Reason                                                  |
| ----- | ------------------------------------------------------- |
| `400` | Missing `id` query parameter.                           |
| `401` | Missing tenant context.                                 |
| `403` | Insufficient role (requires `admin`).                   |
| `404` | Constraint not found or does not belong to this tenant. |

***

## Role requirements

| Method   | Minimum role |
| -------- | ------------ |
| `GET`    | `viewer`     |
| `POST`   | `editor`     |
| `PUT`    | `editor`     |
| `DELETE` | `admin`      |

***

## Runtime paths

Cross-offer constraints drive two execution paths that both read the `cross_offer_constraints` table. Since 2026-07-03 a single loader (`resolveCrossOfferConstraints` in `src/lib/ranking/cross-offer.ts`) translates the canonical rows written by this API — `config.cap` plus `config.channels`/`config.offerIds`/`config.categories` — for **both** the realtime and batch solvers, so rows created here feed both paths.

### Realtime path — `/recommend` hot path

**File:** `src/lib/ranking/cross-offer.ts` (`loadCrossOfferConstraints` → `buildCrossOfferConstraints`)

Activated when `tenantSettings.aiAnalyzerSettings.ranking.lagrangianEnabled` is `true`. The solver loads all `active` rows, builds a per-candidate cost vector for each constraint, and runs a single Lagrangian solve that combines per-offer budget/inventory constraints with these cross-offer constraints. The `crossOfferEnabled` flag is **not** consulted on this path — enabling `lagrangianEnabled` is sufficient.

Constraints are silently skipped (not applied) when:

* `config.cap` is missing, non-finite, or negative.
* All candidates produce a cost of `0` for a given constraint (avoids diluting solver attention on irrelevant constraints).

### Batch path — `batch-executor.ts`

**File:** `src/lib/ranking/cross-offer.ts` (`resolveCrossOfferConstraints`)

Activated when both `lagrangianEnabled` and `crossOfferEnabled` are `true`. This resolver reads the **same canonical rows** as the realtime path (`config.cap` + `config.channels`/`config.offerIds`/`config.categories`) — the former `load-cross-offer.ts` loader that expected a separate `config.rhs` sub-shape was removed on 2026-07-03 because it silently dropped every API-written row. Rule-type coverage differs slightly on the batch path:

* **`portfolio_budget`** — `applicableOfferIds` is `config.offerIds` intersected with the candidate set. The batch solver models one scalar cost per selection, so it reads `config.costPerSelection` (default `1` = selection-count capping) and logs when it falls back to the default. Per-dollar `costPerAction` costs are a realtime-path feature.
* **`category_cap`** — offers in the candidate set whose category name (the `Category` relation, falling back to the legacy `Offer.category` string) matches `config.categories`, case-insensitive.
* **`channel_quota`** — **skipped** on the batch path (with a `warn` log) because the per-offer batch model has no per-candidate channel context. Only the realtime path enforces `channel_quota`.

### Enable the realtime path

```bash theme={null}
curl -X PUT https://playground.kaireonai.com/api/v1/ai/analyzer-settings \
  -H "Content-Type: application/json" \
  -H "X-API-Key: krn_your_api_key" \
  -H "X-Tenant-Id: your-tenant-id" \
  -d '{
    "ranking": {
      "lagrangianEnabled": true
    }
  }'
```

See [AI Analyzer Settings](/api-reference/ai) for the full settings reference.

***

## Operational notes

### Per-pick cost semantics

Cross-offer costs are assessed per candidate (per creative-pick). If two creatives for the same offer are both ranked, each pays the full cost toward the cap. The intent is "N picks toward the cap when N creatives are picked." A per-offer (rather than per-creative) rule type is on the roadmap. The current workaround is to limit candidates to one creative per offer upstream of the rank node.

### Monitoring

When the realtime wire runs, `pipeline-runner.ts` emits a `realtime ranking applied` log line that includes `crossOfferConstraintCount`. A value of `0` when you expect active constraints indicates either that the constraints were dropped (check the `warn`-level `cross-offer constraint load failed` log) or that all candidates produced zero cost for every constraint.

```
INFO realtime ranking applied {
  tenantId, customerId,
  candidateCount,
  perOfferConstraintCount,
  crossOfferConstraintCount,
  noOp, solverFailed, converged, iterations
}
```

### Failure behavior

The cross-offer loader (`loadCrossOfferConstraints`) returns `[]` on any Prisma error and logs at `warn` level. The solver then proceeds with per-offer constraints only. The realtime path never returns a 5xx because of a constraint-loading failure.

***

## Related

* [Lagrangian Ranking](/decisioning/ranking-lagrangian) — how the solver uses these constraints.
* [AI Analyzer Settings](/api-reference/ai) — enable `lagrangianEnabled` and `crossOfferEnabled` flags.
* [Ranking Profiles API](/api-reference/ranking-profiles) — multi-objective weight configuration.
* [Budgets API](/api-reference/budgets) — per-offer budget and inventory hard constraints.
