Skip to Content
Internal docs are powered by Nextra Docs Theme.
Incidents2026Lesaka Jira resolution SLA mismatch

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:

LayerSymptomFindingFix
Dashboard coverageDashboard 63 vs engine 72The 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 exportEngine 72 vs customer summary 83Lesaka’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 semanticsTwo genuine ticket-level disagreementsReopened 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:

Source24h breaches48h breachesNotes
Lesaka Claude summary tab8340Does not agree with Lesaka’s own detail rows.
Rulebase dashboard6323Undercounted because the daily detector skipped old breached candidates.
Rulebase engine recompute7230Best 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:

  1. Backfill org 38 with scripts/backfill-sla-target-outcomes.ts --organization-id=38.
  2. Increase the detector cap.
  3. 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:

AtFromTo
2026-05-20 13:44:59 +0200Waiting for SupportField Info Completion
2026-05-20 13:45:01 +0200Field Info CompletionResolved
2026-05-20 13:46:03 +0200ResolvedClosed
2026-05-22 13:56:00 +0200ClosedAwaiting Refund
2026-05-26 08:59:23 +0200Awaiting RefundResolved
2026-05-26 08:59:39 +0200ResolvedIn Progress
2026-05-26 09:00:23 +0200In ProgressWaiting for 3rd Party
2026-05-27 12:54:41 +0200Waiting for 3rd PartyResolved
2026-05-27 12:55:44 +0200ResolvedClosed

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/changelog

We 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 statusRulebase meaning
ResolvedResolved
DoneResolved
ClosedTerminal 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.rb

New behavior:

ColumnNew Jira behavior
first_resolved_atFirst changelog transition whose to status is Resolved or Done. Written only if blank.
resolved_atCurrent/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_atCurrent/final close timestamp. Overwritten on every sync. Null unless the current status is Closed.
handling_timeFirst 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 statusresolved_atclosed_at
Resolved or DoneLast transition into Resolved or Donenil
ClosedLast transition into Resolved or Done, if anyLast transition into Closed
Any open statusnilnil

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.rb

Backfill behavior:

  1. Select Jira conversations with an organization_data_source_id.
  2. If first_resolved_at is blank and old resolved_at is present, seed first_resolved_at from old resolved_at.
  3. Re-run the Jira changelog timestamp derivation for the conversation.
  4. Overwrite resolved_at and closed_at with 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.rb

Coverage added:

  • Jira derives timestamps from changelog transitions, not status.values.
  • Resolved and Done set first_resolved_at and current resolved_at.
  • Closed sets closed_at but does not count as a resolution event by itself.
  • Existing resolved_at and closed_at are overwritten with current/final values.
  • Existing first_resolved_at is immutable.
  • Reopened tickets currently in open statuses have resolved_at = nil and closed_at = nil.
  • handling_time uses first resolution, not final resolution.
  • Backfill seeds first_resolved_at from the old resolved_at before 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 detected

The 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:

ColumnExpected value
first_resolved_at2026-05-20 13:45:01 +0200
resolved_at2026-05-27 12:54:41 +0200
closed_at2026-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.ts was not changed.
  • Intercom sync was not changed; it already writes first_resolved_at correctly.
  • Jira status-change conversation_parts import was not changed.
  • SLA pause logic was not changed.

Remaining rollout steps

  1. Ship the Jira sync change.
  2. Run the Jira timestamp backfill.
  3. Backfill SLA target outcomes for organization 38.
  4. Raise the SLA detector cap and switch candidate ordering to oldest-first so the breached-ticket tail drains.
  5. Recompute Lesaka’s dashboard numbers and explain the remaining delta as the customer’s summary-tab inconsistency unless their export changes.
Last updated on