Lesaka Jira resolution SLA mismatch (June 2026)
Summary
Lesaka ADP, organization 38, reported that their SLA breach counts did not match Rulebase. Their Claude export summary showed 83 24-hour breaches and 40 48-hour breaches for the week of May 20, 2026. The Rulebase dashboard showed 63 and 23, while a direct engine recompute produced 72 and 30.
The investigation found three separate explanations stacked on top of each other:
| Layer | Symptom | Finding | Fix |
|---|---|---|---|
| Dashboard coverage | Dashboard 63 vs engine 72 | The daily detector was capped at 3,000 candidates across all orgs, newest-first, so it did not drain the slow breached-ticket tail. | Backfill org 38, then raise the detector cap and run oldest-first. |
| Customer export | Engine 72 vs customer summary 83 | Lesaka’s detail sheet and summary tab disagreed. The 482-row detail sheet implied 71 breaches; the summary tab said 83. | Explain that Rulebase matches their detail sheet, not their summary tab. |
| Per-ticket semantics | Two genuine ticket-level disagreements | Reopened Jira tickets used first-resolution semantics in Rulebase but final-resolution semantics in Lesaka’s file. | Switch Jira sync to store both first and final/current resolution timestamps. |
No pause-logic problem was found. Business hours, weekends, and pause over-credit were ruled out by ticket-level diagnostics.
Customer-visible numbers
The three relevant breach counts were:
| Source | 24h breaches | 48h breaches | Notes |
|---|---|---|---|
| Lesaka Claude summary tab | 83 | 40 | Does not agree with Lesaka’s own detail rows. |
| Rulebase dashboard | 63 | 23 | Undercounted because the daily detector skipped old breached candidates. |
| Rulebase engine recompute | 72 | 30 | Best Rulebase recompute before changing Jira resolution semantics. |
The customer’s 482-row detail sheet implied 71 breaches for the relevant week. Rulebase’s engine agreed with 69 of those 71 ticket-level outcomes. The two real disagreements were reopened-ticket resolution semantics.
Root cause
1. Dashboard coverage gap
The dashboard did not reflect the engine recompute because the SLA target outcome detector was capped at 3,000 candidates across all organizations and processed candidates newest-first. That meant it repeatedly consumed newer tickets and did not reach the older slow tail where many breached Lesaka tickets lived.
This explains why the dashboard showed 63 24-hour breaches while the engine recompute found 72.
The remediation is operational and separate from the Jira timestamp change:
- Backfill org 38 with
scripts/backfill-sla-target-outcomes.ts --organization-id=38. - Increase the detector cap.
- Process candidates oldest-first so the tail drains.
2. Customer export internal inconsistency
Lesaka’s summary tab said 83 breaches, but the detailed ticket rows did not support that number. The 482-row detail sheet implied 71 breaches. The Rulebase engine result matched the detail sheet closely, with only two genuine per-ticket disagreements.
The apparent 11-ticket gap between Rulebase’s 72 and the customer’s 83 is therefore mostly the customer’s summary over-counting relative to their own detail export.
3. Jira first-resolution vs final-resolution semantics
The two genuine ticket-level disagreements were reopened tickets, including ESP-77214 and ESP-77120.
Before the fix, Conversation::Sync::Jira wrote resolved_at and closed_at only if the columns were blank. That preserved the first resolution and first close. The first_resolved_at column existed on conversations, but Jira sync never populated it.
Lesaka measured reopened tickets to the final resolution. Rulebase measured them to the first resolution.
For ESP-77214, production data before the fix showed:
{
conversation_id: 3220931,
issue_key: "ESP-77214",
external_id: "401884",
created_at: "2026-05-20 11:44:54 UTC",
first_resolved_at: nil,
resolved_at: "2026-05-20 11:45:01 UTC",
closed_at: "2026-05-20 11:46:03 UTC"
}The Jira changelog for the same ticket showed multiple lifecycle cycles:
| At | From | To |
|---|---|---|
| 2026-05-20 13:44:59 +0200 | Waiting for Support | Field Info Completion |
| 2026-05-20 13:45:01 +0200 | Field Info Completion | Resolved |
| 2026-05-20 13:46:03 +0200 | Resolved | Closed |
| 2026-05-22 13:56:00 +0200 | Closed | Awaiting Refund |
| 2026-05-26 08:59:23 +0200 | Awaiting Refund | Resolved |
| 2026-05-26 08:59:39 +0200 | Resolved | In Progress |
| 2026-05-26 09:00:23 +0200 | In Progress | Waiting for 3rd Party |
| 2026-05-27 12:54:41 +0200 | Waiting for 3rd Party | Resolved |
| 2026-05-27 12:55:44 +0200 | Resolved | Closed |
Rulebase had stored the May 20 first resolution and first close. Lesaka was using the May 27 final resolution and close. Pause calculations matched exactly, so the discrepancy was timestamp semantics, not SLA pause logic.
Data source decision
The authoritative Jira source is now the Jira changelog endpoint:
GET /rest/api/3/issue/:issue_key/changelogWe deliberately do not derive resolution timestamps from the Service Desk status.values list. status.values can expose multiple resolved/closed entries, but it is not the right lifecycle source because it does not provide the same authoritative status from/to transition history and can hide or flatten instant revert cycles. The changelog captures exact transitions and is already paginated in the sync code.
Resolution status mapping:
| Jira status | Rulebase meaning |
|---|---|
Resolved | Resolved |
Done | Resolved |
Closed | Terminal closed, not a resolution event by itself |
Momentary transitions into Resolved or Done count as resolution events. There is no dwell-time filter.
Fix
The Rails sync now stores first-resolution and final/current-resolution semantics separately.
Changed file:
rulebase-api/app/models/conversation/sync/jira.rbNew behavior:
| Column | New Jira behavior |
|---|---|
first_resolved_at | First changelog transition whose to status is Resolved or Done. Written only if blank. |
resolved_at | Current/final resolution timestamp. Overwritten on every sync. Null for currently open tickets. For currently closed tickets, this is the last prior Resolved or Done transition. |
closed_at | Current/final close timestamp. Overwritten on every sync. Null unless the current status is Closed. |
handling_time | First resolution minus external_created_at. Written only if blank. |
The implementation builds sorted changelog status transitions as:
{ at:, from:, to: }Then it computes timestamps in one pass using the final status:
| Current final status | resolved_at | closed_at |
|---|---|---|
Resolved or Done | Last transition into Resolved or Done | nil |
Closed | Last transition into Resolved or Done, if any | Last transition into Closed |
| Any open status | nil | nil |
Status-change conversation_parts are still imported exactly as before through attribute_updated_by_admin parts. That path was intentionally left alone because SLA pause computation depends on it.
Backfill
The one-off backfill task was updated in:
rulebase-api/app/tasks/maintenance/backfill_jira_resolution_timestamps_task.rbBackfill behavior:
- Select Jira conversations with an
organization_data_source_id. - If
first_resolved_atis blank and oldresolved_atis present, seedfirst_resolved_atfrom oldresolved_at. - Re-run the Jira changelog timestamp derivation for the conversation.
- Overwrite
resolved_atandclosed_atwith current/final values, including nulling them for currently open tickets.
This backfill should run after the sync change ships. The API2 SLA target outcome code was not changed because it already reads:
resolved_at ?? closed_at ?? now()Once the columns are repopulated, SLA target outcomes pick up the new semantics automatically.
Verification
Specs were updated in:
rulebase-api/spec/models/conversation/sync/jira_spec.rb
rulebase-api/spec/tasks/maintenance/backfill_jira_resolution_timestamps_task_spec.rbCoverage added:
- Jira derives timestamps from changelog transitions, not
status.values. ResolvedandDonesetfirst_resolved_atand currentresolved_at.Closedsetsclosed_atbut does not count as a resolution event by itself.- Existing
resolved_atandclosed_atare overwritten with current/final values. - Existing
first_resolved_atis immutable. - Reopened tickets currently in open statuses have
resolved_at = nilandclosed_at = nil. handling_timeuses first resolution, not final resolution.- Backfill seeds
first_resolved_atfrom the oldresolved_atbefore refreshing final timestamps from changelog.
Local verification:
bundle exec rspec spec/models/conversation/sync/jira_spec.rb spec/tasks/maintenance/backfill_jira_resolution_timestamps_task_spec.rb
# 50 examples, 0 failures
bundle exec rubocop app/models/conversation/sync/jira.rb app/tasks/maintenance/backfill_jira_resolution_timestamps_task.rb spec/models/conversation/sync/jira_spec.rb spec/tasks/maintenance/backfill_jira_resolution_timestamps_task_spec.rb
# 4 files inspected, no offenses detectedThe RSpec run printed the existing local test DB environment mismatch warning during schema load, but the specs completed successfully.
Expected post-sync and post-backfill values for ESP-77214:
| Column | Expected value |
|---|---|
first_resolved_at | 2026-05-20 13:45:01 +0200 |
resolved_at | 2026-05-27 12:54:41 +0200 |
closed_at | 2026-05-27 12:55:44 +0200 |
The May 26 16-second Resolved -> In Progress blip counts as a resolution event for first-resolution purposes if it were the first one, but it does not become final resolved_at because the ticket ultimately resolved and closed again on May 27.
What we did not change
rulebase-web/rulebase-api2/src/.../sla-target-outcomes.tswas not changed.- Intercom sync was not changed; it already writes
first_resolved_atcorrectly. - Jira status-change
conversation_partsimport was not changed. - SLA pause logic was not changed.
Remaining rollout steps
- Ship the Jira sync change.
- Run the Jira timestamp backfill.
- Backfill SLA target outcomes for organization 38.
- Raise the SLA detector cap and switch candidate ordering to oldest-first so the breached-ticket tail drains.
- Recompute Lesaka’s dashboard numbers and explain the remaining delta as the customer’s summary-tab inconsistency unless their export changes.