Skip to main content
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

ParameterTypeDescription
statusstringFilter by status. One of active, inactive, archived. Omit to return all statuses.
limitnumberMax items per page (default 50, max 100).
cursorstringOpaque 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

CodeReason
401Missing tenant context.
403Invalid 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

FieldRequiredTypeDescription
nameYesstring (1-255)Unique constraint name within the tenant. HTML tags are stripped.
ruleTypeYesstringOne of channel_quota, portfolio_budget, category_cap. Determines which config shape is expected.
configYesobjectRule-type-specific config. See shapes below.
scopeNostringOne of global, category, sub-category, channel, offer-set. Default global.
scopeIdNostring | nullScoping ID used by the batch solver to narrow which offers a constraint applies to. Not consumed by the realtime solver — see Runtime paths below.
statusNostringOne 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
  }
}
FieldTypeDescription
channelsstring[] (min 1)Channel IDs to apply the cap to. Each offer’s channelId is checked against this list.
capnumber (>= 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
  }
}
FieldTypeDescription
offerIdsstring[] (min 1)Offer IDs whose spend counts toward the cap.
capnumber (>= 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
  }
}
FieldTypeDescription
categoriesstring[] (min 1)Category names to apply the cap to. Matching is case-insensitive.
capnumber (>= 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

CodeReason
400Validation error — missing required field, invalid ruleType, or config shape mismatch.
409A constraint with the same name already exists for this tenant.
401Missing 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

FieldRequiredTypeDescription
idYesstring (UUID)The constraint ID to update.
nameNostring (1-255)New name. Must be unique per tenant.
scopeNostringUpdated scope.
scopeIdNostring | nullUpdated scope ID. Sending null clears it.
ruleTypeNostringUpdated rule type.
configNoobjectUpdated config object. Full replacement — no deep merge.
statusNostringUpdated status. Set to inactive to take a constraint out of rotation without deleting it.

Response 200

Returns the updated constraint row.

Error codes

CodeReason
400Validation error.
404Constraint not found or does not belong to this tenant.
409Updated name collides with an existing constraint for this tenant.
401Missing 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

ParameterRequiredDescription
idYesUUID of the constraint to delete.

Response 200

{ "success": true }

Error codes

CodeReason
400Missing id query parameter.
404Constraint not found or does not belong to this tenant.
401Missing tenant context.

Role requirements

MethodMinimum role
GETviewer
POSTeditor
PUTeditor
DELETEadmin

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 (loadCrossOfferConstraintsbuildCrossOfferConstraints) 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.