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.

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.