Claim Workflow Architecture

Created Feb 27, 2026 public

How courier workflows are structured, what the base provides, and how couriers customize behavior.

Two-Layer Inheritance

classDiagram
    class ClaimWorkflowBase {
        #ClaimWorkflowState _state
        #Config : WorkflowConfiguration
        #ShouldComplete : bool
        #CompletionReason : string
        #EmailQueue : Queue
        #CurrentEmail : EmailData
        +RunAsync()*
        ~CreateReminderHandler()
    }
    class ClaimWorkflow~TState~ {
        #State : TState
        #CreateState()
        #RestoreState()
    }
    class AsendiaClaimWorkflow {
        uses base template ✓
    }
    class RoyalMailClaimWorkflow {
        uses base template ✓
    }
    class DHLClaimWorkflow {
        own RunAsync loop
    }
    class DPDClaimWorkflow {
        own RunAsync loop
    }
    class InPostClaimWorkflow {
        own RunAsync loop
    }

    ClaimWorkflowBase <|-- ClaimWorkflow~TState~
    ClaimWorkflowBase <|-- DHLClaimWorkflow
    ClaimWorkflowBase <|-- DPDClaimWorkflow
    ClaimWorkflowBase <|-- InPostClaimWorkflow
    ClaimWorkflow~TState~ <|-- AsendiaClaimWorkflow
    ClaimWorkflow~TState~ <|-- RoyalMailClaimWorkflow

    style ClaimWorkflowBase fill:#e1f5fe
    style ClaimWorkflow~TState~ fill:#e8f5e9

ClaimWorkflowBase provides shared infrastructure: signals, queries, state management, email processing pipeline, reminder orchestration, and activity helpers.

ClaimWorkflow<TState> adds typed state — couriers that use the base template inherit from this and get a State property with their specific type (e.g., AsendiaClaimWorkflowState).

Couriers that need full control over their main loop (DHL, DPD, InPost) inherit directly from ClaimWorkflowBase and write their own [WorkflowRun] RunAsync.

Base Template Lifecycle

Couriers using the base template (RunAsync in ClaimWorkflowBase) follow this flow:

graph TD
    Start([RunAsync]) --> Init[Initialize State & Config]
    Init --> Backfill{IsBackfill?}
    Backfill -->|Yes| BF[InitializeFromBackfillAsync]
    Backfill -->|No| PreSub
    BF --> PreSub

    PreSub[OnPreSubmissionAsync] --> Sub[OnSubmissionAsync]
    Sub --> PostSub[OnPostSubmissionAsync]
    PostSub --> Check{ShouldComplete?}
    Check -->|Yes| Complete
    Check -->|No| Loop

    Loop[Email Loop] --> Wait[Wait for email signal or timeout]
    Wait -->|Email| Process[OnProcessEmailAsync]
    Wait -->|Timeout| Complete
    Process --> Remind[CheckAndSendRemindersAsync]
    Remind --> CAN{Continue-as-new?}
    CAN -->|Yes| Throw[CreateContinueAsNewException]
    CAN -->|No| Check2{ShouldComplete?}
    Check2 -->|Yes| Complete
    Check2 -->|No| Loop

    Complete[OnPreWorkflowCompleteAsync] --> Final([FinalizeWorkflow])

    style PreSub fill:#fff3e0
    style Sub fill:#fff3e0
    style PostSub fill:#fff3e0
    style Process fill:#fff3e0
    style Complete fill:#fff3e0

The orange nodes are lifecycle hooks — virtual methods that couriers override to inject behavior at each stage.

Lifecycle Hooks

Submission Phase

Hook When Default
OnPreSubmissionAsync Before submission begins No-op
OnSubmissionAsync Submit the claim to the courier No-op
OnPostSubmissionAsync After submission completes No-op

Email Processing

The base provides a standard email pipeline (classify → fetch details → update status → handle attachments → notify). Hooks let couriers inject logic at key points:

Hook When Default
OnPreEmailProcessedAsync() Before classification begins No-op
OnPreClaimStatusUpdateAsync(classification, processingData) Before claim status update No-op
OnPostClaimStatusUpdateAsync(classification, result) After status update succeeds No-op
OnPostEmailProcessedAsync(classification) After all email processing completes No-op

All email hooks can access CurrentEmail — the base sets this property before hooks run, so there's no need to pass it through every signature.

Other Hooks

Hook When Default
CreateReminderHandler() During initialization Returns default ReminderHandler
CreateState(request) State initialization Returns base ClaimWorkflowState
CreateContinueAsNewException() When email count exceeds threshold Throws NotImplementedException
GetEmailTimeout() Determines how long to wait for emails Config-driven (default 90 days)
GetSlackLoggingChannel() Slack channel for activity logging null (disabled)
GetCourierReminderEmailAddress() Abstract — where to send reminders Must implement
GetCourierEscalationEmailAddress() Where to escalate after 3 reminders Falls back to reminder address

Example: Asendia (Base Template)

Asendia uses the base template with minimal overrides. It adds email-based claim submission and a custom state type:

graph TD
    A[RunAsync] -->|delegates to| B[base.RunAsync]
    B --> C[OnSubmissionAsync ✱]
    C --> D[Wait → submit email to Asendia]
    D --> E[Email Loop — uses base pipeline entirely]
    E --> F[Reminders — default ReminderHandler]

    style C fill:#fff3e0

What Asendia overrides:

Override Purpose
OnSubmissionAsync Sends claim submission email to Asendia after a delay
CreateContinueAsNewException Passes typed AsendiaClaimWorkflowState
GetCourierReminderEmailAddress Returns Asendia UK email
GetCourierEscalationEmailAddress Returns Asendia UK escalation email
GetSlackLoggingChannel Routes to #temporal-asendia-logs

Everything else — email classification, status updates, reminders, continue-as-new — is inherited from the base.

Example: Royal Mail (Base Template, Heavy Customization)

Royal Mail uses the base template but overrides almost every phase. It has a multi-phase pre-submission, web-form submission, pattern-matched email processing (no LLM), and no reminders:

graph TD
    A[RunAsync] -->|delegates to| B[base.RunAsync]
    B --> C[OnPreSubmissionAsync ✱]
    C --> D[Tracking Enrichment with backoff]
    D --> E[Validation with waiting period]
    E --> F[OnSubmissionAsync ✱]
    F --> G[Web form submission with retry loop]
    G --> H{ShouldComplete?}
    H -->|No| I[Email Loop]
    I --> J[OnProcessEmailAsync ✱]
    J --> K[Pattern matching — no LLM]
    K --> L[Process claim-received or conclusion]

    style C fill:#fff3e0
    style F fill:#fff3e0
    style J fill:#fff3e0

What Royal Mail overrides:

Override Purpose
OnPreSubmissionAsync Tracking enrichment + validation (with backfill phase skipping)
OnSubmissionAsync Web form submission via browser automation queue
OnProcessEmailAsync Replaces entire pipeline — pattern matching instead of LLM
CreateState Adds RM-specific fields (CollectionDate, ServiceCode, etc.)
CreateReminderHandler Returns default handler (RM doesn't send reminders)
GetEmailTimeout 5 years — RM response times are unpredictable
GetSlackLoggingChannel Routes to #automation-feed

Override Matrix

Quick reference of which couriers override which hooks:

Hook Asendia Royal Mail DHL DPD InPost
GetWorkflowName
GetCourierReminderEmailAddress
GetCourierEscalationEmailAddress
GetSlackLoggingChannel
CreateReminderHandler
CreateState ✓*
OnPreSubmissionAsync
OnSubmissionAsync
OnPreClaimStatusUpdateAsync
OnProcessEmailAsync
CreateContinueAsNewException
GetEmailTimeout
Own RunAsync loop

*Asendia's CreateState is via ClaimWorkflow<TState> generic base, not a direct override.

Protected Accessors

Courier workflows access base state through property accessors rather than fields:

Accessor Type Access Used By
Config WorkflowConfiguration Read (write via private protected set) All couriers
ShouldComplete bool Read/Write RM, DHL, DPD, InPost
CompletionReason string Read/Write RM, DHL, DPD, InPost
EmailQueue Queue<EmailReceivedSignal> Read (mutate via Dequeue) DHL, DPD, InPost
CurrentEmail EmailData Read-only (set by base) DHL
State TState Read-only (via ClaimWorkflow<TState>) Asendia, RM