Claim Workflow Architecture
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:#e8f5e9ClaimWorkflowBase 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:#fff3e0The 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:#fff3e0What 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:#fff3e0What 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 |