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


List cross-offer constraints

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

Query parameters

Response 200

Error codes


Create a cross-offer constraint

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

Request body — shared fields

config shapes by ruleType

channel_quota

Caps the number of offers selected from the listed channels in a single solve.
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.
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.
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


Update a cross-offer constraint

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

Request body

Response 200

Returns the updated constraint row.

Error codes


Delete a cross-offer constraint

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

Query parameters

Response 200

Error codes


Role requirements


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

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_budgetapplicableOfferIds 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_quotaskipped 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

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.

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.