ADR-0020: Overdue Task Escalation is Notify-Only — No Auto-Reassignment

Status

Accepted

Tags

fieldforce, overdue, escalation, notifications

Decision

When a fieldforce task remains overdue beyond the org’s escalation_after_hours threshold, the system sends a push notification to the next-role-up (supervisor → manager → admin) and sets escalated_at on the task. The task’s assigned_to is unchanged. The manager decides whether to reassign, extend the deadline, or cancel the task manually.

Why

Auto-reassignment requires answers to questions the system cannot resolve: which specific user in the next role receives the task, what happens to the original assignee’s accountability, and how to handle tasks that are nearly complete when escalation fires. These decisions require business context only a human manager has. Rejected alternatives:
  • Auto-reassign to next role: Changes assigned_to, creates an audit entry. “The manager” is not a single user in multi-manager orgs. Destructive if the original assignee was about to complete the task. Rejected.
  • Visibility-change escalation: Expand AllowedUserIDs so escalation targets can see the task. Adds a new scoping concept with no existing precedent. Rejected.

How it works

Overdue background job (time.NewTicker, 1-minute interval)
Two independent passes per tick:

Pass 1 — Notify:
  SELECT id, org_id FROM ff_tasks
  WHERE due_date + (org config overdue_notify_after_hours * interval '1 hour') < now()
    AND status NOT IN ('approved', 'cancelled')
    AND overdue_notified_at IS NULL
  → For each task:
      notify assignees + manager/supervisor (push notification)
      UPDATE ff_tasks SET overdue_notified_at = now() WHERE id = ?

Pass 2 — Escalate:
  SELECT id, org_id, created_by FROM ff_tasks
  WHERE due_date + (org config escalation_after_hours * interval '1 hour') < now()
    AND status NOT IN ('approved', 'cancelled')
    AND escalated_at IS NULL
  → For each task:
      resolve next-role-up from assignee roles (field_team → supervisor → manager → admin)
      notify next-role-up (push notification only — task unchanged)
      UPDATE ff_tasks SET escalated_at = now() WHERE id = ?
New columns on ff_tasks (migration 005):
ALTER TABLE ff_tasks
  ADD COLUMN overdue_notified_at timestamptz,
  ADD COLUMN escalated_at        timestamptz;
Both default to NULL. Neither is included in API responses — they are internal job state. Per-org timing is read from org_module_configs (ADR-0019) on each tick. Defaults: overdue_notify_after_hours = 0 (notify immediately when overdue), escalation_after_hours = 24.

Known limitations

  • Single escalation: escalated_at is set once. If the task remains unresolved after escalation, no further automated action occurs. The manager must intervene.
  • 1-minute delay: Notifications fire within 1 minute of the threshold crossing, not instantly. For overdue_notify_after_hours = 0, the first notification may be up to 1 minute late.
  • Role resolution ambiguity: In orgs with multiple managers, all managers in the org receive the escalation notification. No single manager is designated.

Rules for agents

  • The escalation job MUST NOT change assigned_to, status, or any other task field — it only sets escalated_at and sends a notification
  • Tasks with status IN ('approved', 'cancelled') MUST be skipped in both passes — do not notify on terminal tasks
  • Both overdue_notified_at and escalated_at MUST be set atomically with the notification — if the notification fails, do not set the column (retry on next tick)
  • Do not expose overdue_notified_at or escalated_at in API responses — these are internal job state columns
  • Never send a second overdue notification or second escalation for the same task — the NULL check is the idempotency guard

Bad pattern (do not generate)

// Auto-reassigning on escalation — changes ownership without human decision
task.AssignedTo = []string{managerID} // wrong — notify only, never reassign
repo.Update(ctx, task)

// Notifying without setting the guard column — causes duplicate notifications
notificationService.Send(ctx, notification)
// forgot: repo.SetEscalatedAt(ctx, task.ID) — will fire again next tick

// Including escalation columns in API response
type TaskResponse struct {
    // ...
    OverdueNotifiedAt *time.Time `json:"overdue_notified_at"` // wrong — internal only
    EscalatedAt       *time.Time `json:"escalated_at"`        // wrong — internal only
}

Good pattern

func (j *OverdueJob) runEscalatePass(ctx context.Context) {
    tasks := j.taskRepo.FindEscalationCandidates(ctx) // escalated_at IS NULL + threshold passed
    for _, task := range tasks {
        nextRole := resolveNextRoleUp(task.AssignedToRoles)
        recipients := j.memberRepo.FindByOrgAndRole(ctx, task.OrgID, nextRole)

        if err := j.notificationService.Send(ctx, Notification{
            Recipients: recipients,
            Type:       "task_escalated",
            TaskID:     task.ID,
        }); err != nil {
            j.logger.Error("escalation notification failed", zap.Error(err))
            continue // do not set escalated_at — retry next tick
        }

        // Only set the guard after successful notification
        j.taskRepo.SetEscalatedAt(ctx, task.ID, time.Now())
        // task.AssignedTo unchanged — manager decides next action
    }
}