Skip to main content

What a decision trace is

Every time /api/v1/recommend runs, the engine takes a list of candidate offers and pushes them through four stages — qualification (which offers is this customer eligible for?), contact policy (which of those are we allowed to send right now?), scoring (how good is each remaining offer for this customer?), and ranking (given placements and channel coupling, which N do we return?). A decision trace is the forensic record of that journey: how many candidates entered each stage, which rules fired, which offers were filtered out and why, what scores the models produced, and which offers made the final cut. Traces are written asynchronously after the decision is delivered, so they never slow down /recommend. They are sampled per tenant (decisionTraceEnabled + decisionTraceSampleRate); at 10% sampling a high-traffic tenant still gets enough forensic coverage to investigate any specific customer or incident.

What you see when you open a trace in Studio

Open Studio → Decision Traces and click any row to expand it. The expanded view has three layers, each answering a different question:

1. Stage Timeline — which stage filtered offers, and by how much?

A vertical timeline of the four pipeline stages with the candidate count entering and exiting each one (12 → 4, 4 → 4, 4 → 4, 4 → 4). A green dot means the stage ran cleanly; an amber dot means it ran in degraded mode (a scoring model threw, fallback scores were used). This is the “where did the candidates go?” view. Use it when you suspect a stage is filtering too aggressively or running slow.

2. Per-Offer Journey — for THIS customer and THIS request, why this offer and not that one?

A table with one row per offer ID that appeared anywhere in the trace, showing its trajectory across all four stages on a single line:
  • Qualification — pass (✓) or fail (✗) with the exact reason text and the ruleId of the rule that fired. Example: Missing attribute "tier" (d0cc9358-…).
  • Contact Policy — pass (✓) or blocked (○) with the policy reason and policyId. Example: frequency_cap (pol_email_daily_cap).
  • Score — the numerical score, the rank within the scoring stage, the modelType that produced it (gradient_boosted, thompson_bandit, etc.), and the top-3 signed feature contributions rendered as horizontal bars (green = positive, red = negative).
  • Final — a 🏆 trophy with the rank in the delivered response, or em-dash if the offer didn’t make it.
A header strip above the table shows the experiment variant (when present), ranking weights, and the first 8 chars of inputsHash / policyVersionHash so you can cross-reference against drift logs and policy snapshots. Selected offers are highlighted green. This is the “Why didn’t Customer X get Offer Y?” view. Compliance reviewers, support escalations, and ops engineers debugging weird-looking results live here.

3. Explain button — narrative prose for a human reader

The Explain button at the right of each trace row opens a dialog that calls the LLM-narrated explanation endpoint. The dialog has three tabs — one per audience:
TabWhat it producesWhen to use it
Regulator~400-word formal prose with full factor detail. Writes an audit-log entry so the narrative is discoverable during a DSAR or regulator review.When you need a defensible, compliance-grade explanation for an auditor.
AgentStructured JSON describing the selected offer, top factors, alternatives considered, and policies fired. Machine-readable.Driving a call-center console, a support troubleshooting UI, or any internal tool that needs to consume the explanation programmatically.
CustomerOne or two plain-language sentences, suitable to show to the end customer in-product.Embedding a “why are you seeing this?” caption next to a recommended offer.
A Regenerate button bypasses the 7-day cache (noCache: true); the dialog footer shows which model produced the narrative, whether the result was cached, and the token counts. The Explain button is gated by a per-tenant opt-in (tenantSettings.aiAnalyzerSettings.llmExplanationsEnabled); when it’s off, the button shows a banner that links to settings. See LLM Explanations for the full lifecycle, PII redaction, audit log, and rate limits.

When to reach for which layer

  • “Latency is up, where?” → Stage Timeline.
  • “Customer X is asking why they didn’t get the Premium Card.” → Per-Offer Journey for that customer’s trace.
  • “Our compliance officer wants a written record for the Brown v. Tenant audit.” → Explain → Regulator tab.
  • “The support agent UI needs to render the explanation.” → Explain → Agent tab (JSON).
  • “Embed a one-liner under the offer card in the customer app.” → Explain → Customer tab.

GET /api/v1/decision-traces

List decision traces with offset-based pagination. Traces are only recorded when tracing is enabled in tenant settings and the request passes the sample rate check.

Query Parameters

ParameterTypeDefaultDescription
customerIdstringFilter traces for a specific customer
requestIdstringFilter by request/interaction ID
limitinteger50Max results (max 200)
offsetinteger0Pagination offset

Response

{
  "traces": [
    {
      "id": "trace_001",
      "customerId": "CUST001",
      "requestId": "550e8400-e29b-41d4-a716-446655440000",
      "decisionFlowKey": "default-flow",
      "pipeline": [
        { "stage": "qualification", "candidates": 47, "passed": 32, "durationMs": 12 },
        { "stage": "contact_policy", "candidates": 32, "passed": 28, "durationMs": 8 },
        { "stage": "scoring", "candidates": 28, "durationMs": 15 },
        { "stage": "ranking", "topScore": 0.872, "method": "priority_weighted", "durationMs": 5 }
      ],
      "result": {
        "count": 5,
        "topOffer": { "offerId": "offer_001", "score": 0.872 }
      },
      "totalDurationMs": 42,
      "createdAt": "2026-03-16T14:30:00.000Z"
    }
  ],
  "total": 1250,
  "limit": 50,
  "offset": 0
}

GET /api/v1/decision-traces/

Get a single decision trace with full pipeline detail.

Response

Returns the complete trace object with all pipeline stages, scoring details, and result.

Per-stage timeline (UI)

The studio detail view at /studio/decision-traces/{id} renders a per-stage timeline alongside the JSON trace. Each pipeline stage (Enrich, Compute, Eligibility, Fit, Match, Ranking, Negotiation) is shown as a horizontal bar with the stage label, elapsed milliseconds, and the candidate count before/after. Hovering any bar reveals the rule firings, scoring inputs, and reasons recorded for that stage. Use this to diagnose which stage is slow or filtering more aggressively than expected without scrolling through the raw trace payload.

Provenance deep-dive — “Why this customer got these offers”

Expanding any row at /studio/decision-traces opens a per-offer journey table directly inline. For each offer ID that appeared anywhere in the four JSON arrays, the row shows the offer’s trajectory across all four stages on one line: qualification (pass / fail with the exact rule reason and ruleId), contact policy (pass / blocked with the policy reason and policyId), score (the model’s score, rank, and modelType, plus the top-3 signed feature contributions from scoringResults[].explanations[] rendered as horizontal bars), and final (selected with rank, or not selected). Rows that were selected by the decision flow are highlighted in green. The header strip above the table surfaces, when present:
  • experiment.variant — which experiment arm this decision was assigned to.
  • rankingWeights — the ranking-profile weights (PRIE coefficients, diversity / emphasis multipliers).
  • The first 8 chars of inputsHash and policyVersionHash — for cross-referencing against drift logs or policy snapshots.
  • A degraded badge when degradedScoring = true.
This deep-dive reads only what the trace already persists — there is no new API call. Older traces persisted before the JSON-array enrichment landed render an empty-state caption; the count-based timeline continues to work for them.

Enabling Decision Traces

Decision traces are controlled by two tenant settings:
SettingDescription
decisionTraceEnabledMaster toggle for trace recording
decisionTraceSampleRateSampling rate (0.0 - 1.0). Set to 1.0 to trace every request
Configure these via the Settings API or the platform UI under Settings.
At high traffic volumes, set the sample rate below 1.0 to avoid excessive storage. A 10% sample rate (0.1) typically provides sufficient forensic coverage.

JSON Field Shapes

Each trace persists four JSON arrays that capture the forensic detail of the decision. Downstream aggregators (the selection_frequency, anomaly_candidates, and why_not_ranked endpoints in Dashboard Data) expect the following shapes on newly-written rows. Older rows written before a field was added are still readable — fields default to null in the aggregation queries.

scoringResults

Best-first list of offers that reached the scoring stage.
[
  { "offerId": "off_premium_card", "score": 0.89, "rank": 1 },
  { "offerId": "off_gold_plus", "score": 0.71, "rank": 2 }
]

selectedOffers

The final ranked result returned to the caller, in delivery order.
[
  { "offerId": "off_premium_card", "score": 0.89, "rank": 1 },
  { "offerId": "off_gold_plus", "score": 0.71, "rank": 2 }
]

qualificationResults

One row per offer that the qualification stage evaluated. passed=false indicates a rejection; ruleId identifies the rule that evaluated.
[
  { "offerId": "off_premium_card", "passed": true, "reason": "passed" },
  { "offerId": "off_restricted", "passed": false, "reason": "segment_mismatch", "ruleId": "rule_42" }
]

contactPolicyResults

One row per offer that the contact-policy stage evaluated. blocked=true indicates the policy suppressed the offer for this customer.
[
  { "offerId": "off_premium_card", "blocked": false, "reason": "passed" },
  { "offerId": "off_weekly_promo", "blocked": true, "reason": "frequency_cap", "policyId": "pol_email_daily_cap" }
]

POST /api/v1/decisions//narrative

Generate an LLM-written natural-language explanation of a single decision trace. The feature is opt-in per tenant — see LLM Explanations.

Request Body

{
  "mode": "regulator",
  "noCache": false
}
FieldTypeDefaultDescription
mode"regulator" | "agent" | "customer""regulator"Audience for the explanation. Changes tone, length, and format.
noCachebooleanfalseWhen true, bypass the cache and force a fresh LLM call.

Response

{
  "narrative": "The system recommended offer_premium_card for customer CUST001 because ...",
  "mode": "regulator",
  "model": "claude-sonnet-4-7",
  "cached": false,
  "tokens": { "input": 1420, "output": 310 },
  "createdAt": "2026-04-18T21:04:18.000Z"
}
FieldTypeDescription
narrativestringThe generated explanation. Prose for regulator and customer modes, structured JSON for agent mode.
modestringEchoes the requested mode.
modelstringIdentifier of the LLM used.
cachedbooleantrue if the result was served from Redis.
tokens.input / tokens.outputnumberLLM token counts when reported by the provider.
createdAtISO-8601 stringCreation timestamp of the narrative (cached or fresh).

Auth, Limits, and Tenancy

  • Requires tenant authentication (session or API key).
  • Rate limited to 20 requests / minute / tenant. Exceeding returns 429.
  • Tenant opt-in required: tenantSettings.aiAnalyzerSettings.llmExplanationsEnabled = true. Otherwise returns 403 with "LLM explanations are not enabled for this tenant.".
  • The decision trace must belong to the authenticated tenant or the response is 404.

Audit Log (regulator mode)

When mode = "regulator", a row is appended to the audit log:
{
  "tenantId": "tenant_abc",
  "action": "generate_narrative",
  "entityType": "decision_trace",
  "entityId": "trace_001",
  "entityName": "regulator narrative",
  "changes": {
    "mode": "regulator",
    "model": "claude-sonnet-4-7",
    "cached": false,
    "narrativePreview": "..."
  }
}

Caching

Results are cached in Redis for 7 days under the composite key (tenantId × decisionTraceId × mode × model × inputsHash). Repeating the same request returns cached: true instantly.

POST /api/v1/decisions//shap

Return exact per-feature TreeSHAP contributions for a recorded decision’s gradient_boosted scoring step. Designed for EU AI Act Article 13/22 audit workflows: the response is a mathematically-grounded, additivity-verified breakdown of every feature’s effect on the raw margin (logit space). Unlike the LLM narrative endpoint, this returns raw numbers — deterministic, cacheable, and auditable independent of any LLM availability. Pair the two when you need both prose and numerics in a regulator export.

Request Body

{
  "modelId": "mdl_premium_card_gbm",
  "offerId": "off_premium_card",
  "attributes": {
    "recent_transaction_count": 12,
    "tenure_months": 36,
    "average_balance": 15400,
    "segment_index": 2
  },
  "round": 6
}
FieldTypeDefaultDescription
modelIdstringrequiredThe AlgorithmModel.id used to score the trace. Must be tenant-owned and modelType = "gradient_boosted".
offerIdstringoptionalThe candidate offer the SHAP is being computed for. Echoed in the audit log.
attributesobjectrequiredThe customer/offer/context attributes passed to the scorer at decision time. Required because raw attributes are not persisted on the trace (PII minimization).
roundinteger6Decimal places for output (0 = raw doubles).

Response

{
  "decisionTraceId": "trace_001",
  "modelId": "mdl_premium_card_gbm",
  "modelName": "Premium Card GBM",
  "offerId": "off_premium_card",
  "shapValues": {
    "recent_transaction_count": 1.314,
    "tenure_months": 0.428,
    "average_balance": -0.211,
    "segment_index": 0.083
  },
  "baseline": -0.602,
  "rawMargin": 1.012,
  "additivityResidual": 0.0,
  "featureCount": 4
}
FieldTypeDescription
shapValuesobjectPer-feature Shapley contributions in raw-margin (logit) space.
baselinenumberCover-weighted expected raw-margin across the ensemble.
rawMarginnumberThe chosen leaf-value sum for these attributes. sigmoid(rawMargin) = score.
additivityResidualnumber`rawMargin − baseline − Σ shapValues`. Should be ≈ 0; non-zero indicates a malformed model.
featureCountnumberNumber of features that received a non-zero contribution.

Additivity guarantee

For any input x:
sum(shapValues) + baseline = rawMargin
score(x)                   = sigmoid(rawMargin)
This identity is built into the algorithm (Lundberg, Erion, Lee 2018, Algorithm 2 — path-dependent variant). The endpoint reports additivityResidual so consumers can verify the invariant on-wire.

Probability-space approximation

SHAP values are reported in raw-margin space — the only space where the additivity identity holds. To approximate a feature’s effect on the probability, use:
delta_p_i ≈ sigmoid(baseline + shap_i) − sigmoid(baseline)
This is monotone with shap_i but not strictly additive in probability space. UI surfaces typically show both: the raw φ for compliance, and a sigmoid-mapped delta for human-readable display.

Auth, Limits, and Tenancy

  • Requires tenant authentication (session or API key).
  • Rate limited to 30 requests / minute / tenant.
  • Tenant opt-in required: tenantSettings.aiAnalyzerSettings.llmExplanationsEnabled = true (same flag as the narrative endpoint).
  • The decision trace, the model, and the request must all belong to the same tenant. Cross-tenant lookup returns 404.
  • Returns 400 if the model’s modelType is not gradient_boosted or if it has no trained trees.

Audit Log

Every successful call writes:
{
  "tenantId": "tenant_abc",
  "action": "compute_shap",
  "entityType": "decision_trace",
  "entityId": "trace_001",
  "entityName": "shap for Premium Card GBM / off_premium_card",
  "changes": {
    "modelId": "mdl_premium_card_gbm",
    "modelName": "Premium Card GBM",
    "offerId": "off_premium_card",
    "featureCount": 4,
    "baseline": -0.602,
    "rawMargin": 1.012,
    "additivityResidual": 0.0
  }
}
This ensures DSAR exports and regulator queries can trace which decisions have had SHAP attributions computed and when.

Roles

EndpointAllowed Roles
GET /decision-tracesadmin, editor, viewer
GET /decision-traces/{id}admin, editor, viewer
POST /decisions/{id}/narrativeadmin, editor, viewer (tenant-scoped)
POST /decisions/{id}/shapadmin, editor, viewer (tenant-scoped)
See also: LLM Explanations | Dashboards | Decision Flows