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 viaconsumeBudgetin the/respondflow lastDailyResetDatebookkeeping is handled by the platform
Inventory counters
Offer.inventory.totalStock— starting stockOffer.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
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 pass is enabled.Example offer with constraints
- 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. The
Offer.budget/Offer.inventoryfilter 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_capcontact policy, not byOffer.frequencyCaps(which is strictly per-offer). - Budget alerting / refill workflows are not built in — update
dailyCapCents/lifetimeCapCentsmanually. - 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_windowcontact 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.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.Enabling negotiation
Three gates must pass, all fail-closed:-
Tenant opt-in: the tenant setting
aiAnalyzerSettings.negotiationEnabledmust betrue. Set it with an admin-onlyPUT /api/v1/ai/analyzer-settingsrequest that includes{ "negotiationEnabled": true }in the body; the write merges the flag intoaiAnalyzerSettingswithout disturbing the analyzer-config sections or the runtime-managed ranking state, and the current value is returned byGET /api/v1/ai/analyzer-settings. The negotiate route reads it fromTenantSettingsand fails closed when it is absent orfalse. -
Per-offer flag:
Offer.negotiable = true -
Per-offer guardrails:
Offer.negotiationGuardrailswith explicit bounds.
Guardrail schema
discount is absent, the agent MUST NOT propose a discount.
Calling the agent
proposals: ValidatedProposal[]. Each proposal has:
valid: booleanproposal: { rationale, discountPct?, termMonths?, bundleAddons?, finalPriceCents?, currency? } | nullviolations: GuardrailViolation[]— a typed list from 11 possible violation codes
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
rationalefield →rationale_missing - Exceed
maxProposalsin a single session → trailing proposals markedschema_invalid
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
/recommendhot 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 insrc/lib/negotiation/apply-mode.ts):
applyModeEnabledflag set on the tenantregulatorReviewClearedset (intended for after an offline eval-harness run shows zero guardrail violations)- Neither the tenant nor global kill switch is tripped, and the rolling validation-failure rate is under
autoKillThreshold - The offer is
negotiablewith valid guardrails, and the per-tenant daily apply cap is not exhausted