Awaiting Response — Unified Follow-Up Tracking

Created Mar 2, 2026 public

Proposal to give courier workflows a general-purpose way to say "we sent something, follow up if no reply."

The Problem

The reminder system's reference time chain is:

LastReminderSentAt → LastEmailProcessedAt → WorkflowStartedAt

This works once reminders are flowing, but there's a blind spot: outbound emails that aren't reminders (submission emails, manual follow-ups) don't feed into the chain. After Asendia submits a claim via email on Day 3, the reminder timer still measures from WorkflowStartedAt (Day 0) — not from when we actually contacted the courier.

sequenceDiagram
    participant W as Workflow
    participant C as Courier
    participant R as ReminderHandler

    Note over W: WorkflowStartedAt = Day 0
    W->>C: Submit claim email (Day 3)
    Note over W: SubmittedAt = Day 3<br/>but NOT in reference chain

    Note over R: Reference = WorkflowStartedAt (Day 0)<br/>thinks 10 days passed,<br/>actually only 7 since submission

    R->>C: Reminder fires early

Each courier would need bespoke code to bridge this gap. That's fragile.

Proposed Solution

Add a single concept to ClaimWorkflowBase: awaiting response.

When a courier sends any outbound email — submission, follow-up, anything — it calls a base helper. The reminder system then measures from that point.

The Helper

// In ClaimWorkflowBase
protected void MarkAwaitingResponse()
{
    _state.AwaitingResponseSince = Workflow.UtcNow;
    AddAuditLog("Awaiting courier response — follow-up timer started");
}

Updated Reference Time Chain

graph LR
    A[LastReminderSentAt] -->|fallback| B[AwaitingResponseSince]
    B -->|fallback| C[LastEmailProcessedAt]
    C -->|fallback| D[WorkflowStartedAt]

    style A fill:#e1f5fe
    style B fill:#fff3e0
    style C fill:#e8f5e9
    style D fill:#f3e5f5

The orange node is new. It slots in between "we already sent a reminder" and "we received an email" — capturing "we sent something that wasn't a reminder."

In code (all three places in ReminderHandler that build a reference time):

var referenceTime = _ctx.State.LastReminderSentAt
    ?? _ctx.State.AwaitingResponseSince
    ?? _ctx.State.LastEmailProcessedAt
    ?? _ctx.State.WorkflowStartedAt;

State Change

One new field on ClaimWorkflowState:

public DateTime? AwaitingResponseSince { get; set; }

Reset Behavior

AwaitingResponseSince resets naturally — when an inbound email arrives, LastEmailProcessedAt gets set. When a reminder is sent, LastReminderSentAt gets set. Both take priority in the chain, so the awaiting-response timestamp becomes irrelevant without needing explicit clearing.

If the courier sends another outbound email (not a reminder), calling MarkAwaitingResponse() again updates the timestamp — the clock restarts from the latest outbound.

Usage Across Couriers

Asendia — Email Submission

protected override async Task OnSubmissionAsync(ClaimWorkflowRequest request)
{
    // ... send submission email to Asendia ...
    if (response is { Success: true, WasSubmitted: true })
    {
        State.SubmittedAt = Workflow.UtcNow;
        TransitionTo(ClaimStatuses.DatabaseIds.SubmittedSent);
        MarkAwaitingResponse();  // ← reminders now measure from here
    }
}

Royal Mail — Web Form Submission

protected override async Task OnSubmissionAsync(ClaimWorkflowRequest request)
{
    // ... submit via browser automation ...
    if (submissionSucceeded)
    {
        TransitionTo(ClaimStatuses.DatabaseIds.SubmittedSent);
        MarkAwaitingResponse();  // ← even though it wasn't an email
    }
}

Any Future Courier — Manual Follow-Up

// Some courier sends a "status check" email mid-workflow
await SendStatusCheckEmail();
MarkAwaitingResponse();  // ← clock resets, reminders follow naturally

Couriers That Don't Need It

Couriers that are backfill-only (DHL, DPD, InPost) or don't send outbound emails before reminders simply never call MarkAwaitingResponse(). The field stays null, the chain falls through to LastEmailProcessedAt or WorkflowStartedAt exactly as today. Zero change for existing behavior.

What Happens After MarkAwaitingResponse()

This is the key part — calling it doesn't start some separate timer. It feeds into the existing reminder machinery:

graph TD
    Send[Courier sends email / submits claim] --> Mark[MarkAwaitingResponse]
    Mark --> State["AwaitingResponseSince = now"]

    State --> Loop[Email loop continues waiting]
    Loop --> Wait["WaitConditionAsync(email or timeout)"]

    Wait -->|Email arrives| Process[Process inbound email]
    Process --> Reset["LastEmailProcessedAt = now<br/>(takes priority over AwaitingResponseSince)"]
    Reset --> Normal[Normal flow continues]

    Wait -->|Timeout| Check[ReminderHandler.CheckAndSendAsync]
    Check --> Ref["referenceTime = LastReminderSentAt<br/>?? AwaitingResponseSince ← used here<br/>?? LastEmailProcessedAt<br/>?? WorkflowStartedAt"]
    Ref --> Analyze[ReminderAnalyzer checks email thread]
    Analyze --> Due{Reminder due?}
    Due -->|Yes| Approve[Request human approval]
    Approve -->|Approved| SendReminder[Send reminder email]
    SendReminder --> MarkSent["LastReminderSentAt = now<br/>(takes priority over everything)"]
    Due -->|No| Loop

    style Mark fill:#fff3e0
    style State fill:#fff3e0
    style Ref fill:#fff3e0

The entire reminder lifecycle — intervals, escalation thresholds, human approval, email content, status updates to "Chasing" — all works exactly as before. MarkAwaitingResponse() only affects when the first reminder fires.

Concrete Example: Asendia End-to-End

After calling MarkAwaitingResponse() in OnSubmissionAsync, here's exactly what happens:

timeline
    title Asendia Claim Timeline (default config)
    Day 0 : Workflow starts
    Day 0 : OnSubmissionAsync — sends claim email to Asendia
          : MarkAwaitingResponse → AwaitingResponseSince = Day 0
    Day 7 : ReminderHandler wakes up
          : referenceTime = AwaitingResponseSince (Day 0)
          : 7 days elapsed ≥ interval[0] (7 days)
          : → Reminder #1 fires
    Day 14 : referenceTime = LastReminderSentAt (Day 7)
           : 7 days elapsed ≥ interval[1] (14 days total)
           : → Reminder #2 fires
    Day 21 : referenceTime = LastReminderSentAt (Day 14)
           : 7 days elapsed ≥ interval[2] (21 days total)
           : → Reminder #3 fires (ESCALATION)

How long before first reminder?

Configured via ReminderIntervalsDays in appsettings. Default: [7, 14, 21] days. The first reminder fires 7 days after MarkAwaitingResponse() was called — not 7 days after workflow start.

Per-courier override is possible via config:

{
  "Couriers": {
    "Asendia": { "ReminderIntervalsDays": [10, 20, 30] }
  }
}

Or via code — a courier's ReminderHandler can override GetCustomIntervals().

Where does it go?

Reminder # Resolved by Asendia value
1, 2 GetCourierReminderEmailAddress() customerservice.uk@asendia.com
3+ GetCourierEscalationEmailAddress() Asendia UK escalation address

The escalation threshold (3) is configurable via ReminderHandler.EscalationReminderThreshold.

What does the email say?

ReminderHandler builds the email via overridable methods:

Method Reminder #1 Reminder #3 (escalation)
GetSubject() "Reminder: Claim follow-up for {tracking}" "URGENT: Final reminder for claim {tracking}"
GetBody() "This is a friendly reminder..." "URGENT: This is our final reminder before escalation..."
GetFromAddress() "{ClientName} Claims Team <comms@claimit.ai>" Same

All three methods are virtual on ReminderHandler — couriers can override for custom templates. Every reminder also requires human approval before sending (via the intervention system).

What if the courier replies before Day 7?

The inbound email sets LastEmailProcessedAt, which doesn't take priority over AwaitingResponseSince in the chain — but the ReminderAnalyzer independently checks the actual email thread. If the courier's last email is more recent than ours, the analyzer returns ShouldSendReminder = false. The two systems (reference time chain + email thread analysis) work together.

Scope of Changes

File Change
ClaimWorkflowModels.cs Add AwaitingResponseSince to ClaimWorkflowState
ClaimWorkflowBase.cs Add MarkAwaitingResponse() helper
ReminderHandler.cs Insert AwaitingResponseSince into 3 reference time calculations
AsendiaClaimWorkflow.cs Call MarkAwaitingResponse() after submission
ClaimWorkflowStateReconstructor.cs Reconstruct AwaitingResponseSince from email history (backfill)

No changes to DHL, DPD, InPost, or Royal Mail unless we choose to wire them up too.