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.
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 for background.
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 nextCursor value from the previous response. |
Response 200
{
"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"
}
],
"total": 1,
"nextCursor": null
}
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 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.
{
"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.
{
"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.
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.
category_cap
Caps the number of offers selected from the listed categories.
{
"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. |
409 | A constraint with the same name already exists for this tenant. |
401 | Missing tenant context. |
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. |
404 | Constraint not found or does not belong to this tenant. |
409 | Updated name collides with an existing constraint for this tenant. |
401 | Missing tenant context. |
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
Error codes
| Code | Reason |
|---|
400 | Missing id query parameter. |
404 | Constraint not found or does not belong to this tenant. |
401 | Missing tenant context. |
Role requirements
| Method | Minimum role |
|---|
GET | viewer |
POST | editor |
PUT | editor |
DELETE | admin |
Runtime paths
Cross-offer constraints drive two separate execution paths that read the same cross_offer_constraints table but expect different config sub-shapes. This is a known pre-existing divergence. Rows written via this API use the shapes documented above (config.channels, config.offerIds, config.categories, config.cap) and feed the realtime path only.
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 / load-cross-offer.ts
File: src/lib/ranking/load-cross-offer.ts (resolveCrossOfferConstraints)
Activated when both lagrangianEnabled and crossOfferEnabled are true. This loader reads a different config sub-shape: config.rhs (cap value), config.costPerSelection (per-pick cost, default 1), and resolves scope to applicableOfferIds via joins — the config.channels, config.offerIds, and config.categories keys written by this API are not read on this path. Rows created via this API will appear in the batch solver’s query but will be dropped because config.rhs will be absent.
Operators who need cross-offer constraints to work on both paths must write rows with both sub-shapes populated until this divergence is resolved.
Enable the realtime path
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 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.