A Unified Claim Agent

Created Jul 23, 2026 public

A design for a single rule-driven agent that knows every courier, client, and claim rule, watches claims as they are ingested, and drives each one all the way through to submission and credit.

The central finding: ClaimIt already contains all the machinery. What it lacks is a single brain to tie it together. This document maps what exists, names the problem precisely, and proposes the one component worth building.

The thesis

Today the rules a claim agent should know are smeared across five subsystems — code constants, environment variables, and three separate database config tables. The "agent" is really two different agents plus a fleet of per-courier workflows that each re-derive policy locally.

So the design goal is consolidation, not greenfield: introduce one policy resolver as the source of truth, keep Temporal as the durable execution substrate, and feed a thin dispatcher from ingestion and sweeps.

Three layers that already exist

Every claim-automation system decomposes into the same three layers. ClaimIt has all three — they are just not unified.

What backs each layer today

Layer Component Where it lives
Knowledge Courier rules (code) CourierRegistry / CourierDefinitionEnabled, RunsOnSelfHosted, EmailHandledByWorkflow, AllowsPortalCreatedWorkflows
Knowledge Per-brand rollout gates *WorkflowConstants.EnabledCclIds (e.g. ColissimoWorkflowConstants)
Knowledge Which LLM categories may act AICommsClientConfigServiceEnableCreditLLM, EnableRejectionLLM, … per courier × client
Knowledge Allowed rejection reasons EnabledRejectionReasonIds JSON
Knowledge Bulk-doc eligibility ClaimsAgentGate (remittance / claims_report / bulk_import)
Knowledge Brand on/off switch AICommsClientExclusionService (AgentEnabled / AutoEscalate / NoAction)
Trigger Ingest → classify → dispatch ClassifyLoopProcessEmailDispatcherProcessEmailWorkflow
Trigger Time-based sweeps Temporal schedules (UPS / Erin / stuck-New)
Execution Per-claim state machine ClaimLifecycleBase / ClaimLifecycle<TState>
Execution Sanctioned transitions ClaimTransition.To*() + ClaimStatuses.DatabaseIds
Execution Submission (browser / API / email) per-courier OnSubmissionAsync hook

The end-to-end already works

Colissimo is the clean reference implementation — the per-claim Temporal submission layer built under TECH-923 by Trevor Daniel. A claim runs the full distance today without a human touching it:

ClaimTransition.To*() is the only sanctioned way to change status (never set ClaimStatusId directly), and each transition writes a tagged ClaimLogEntry. The status ids are canonical constants in ClaimStatuses.DatabaseIds.

The problem: no single brain

Here is the crux. The question "can this claim be auto-progressed right now?" has no single owner. Five subsystems each hold a slice, with different mechanisms and different storage:

So "the agent knows the rules" is not true today — five subsystems each know a slice. Onboarding a new brand means editing code constants and database rows and hoping the two agree. There is no place to answer the whole question at once, and no single audit trail for why a claim did or did not advance.

There are also two ClaimTransition.cs files (backend/SDK/ClaimTransition.cs and backend/SDK/Objects/ClaimTransition.cs). Confirming which is canonical is a prerequisite to building anything on top of the transition layer.

Target architecture

Introduce one new component — a ClaimPolicyResolver — and route every gate decision through it. Ingestion and sweeps feed a thin dispatcher; the dispatcher asks the resolver for a policy; the per-claim Temporal workflow carries it out.

The ClaimPolicy object

The resolver returns one immutable record answering the whole question at once:

record ClaimPolicy
{
    bool                IsLive;               // AllowsEmail/PortalCreated + CclId ∈ EnabledCclIds
    Cluster             Cluster;              // from RunsOnSelfHosted
    SubmissionChannel   Channel;              // Browser | Api | Email | Batch
    AgentMode           AgentMode;            // AgentEnabled | AutoEscalate | NoAction
    IReadOnlySet<Category> AutoActionable;    // from the Enable*LLM flags
    IReadOnlySet<int>   AllowedRejectReasons; // EnabledRejectionReasonIds
    EvidenceRequirement Evidence;             // PoP / PoC required?
    TimeSpan?           FilingWindow;
    bool                AllowAccepted;        // [ALLOW_ACCEPTED]; Credited stays agent-blocked
}

The key property is that it wraps the existing sources rather than replacing them. It reads CourierRegistry, the EnabledCclIds constants, AICommsClientConfigService, and AICommsClientExclusionService behind one interface. Every call site that today re-derives policy locally instead asks the resolver. That makes the change low-risk, incremental, and unit-testable in isolation.

The truth is in the code

There is a tempting wrong turn here: make the agent validate claims against a set of courier rule documents. Do not. Those docs are the least-maintained artifact in the system — nobody's job is "doc freshness," and the moment a developer fixes a live courier failure under pressure, the doc silently falls behind reality. (This very design doc's sister page — the Erin claim-system doc — drifted stale within three months of being written.)

The authoritative ruleset already exists, and it lives in the code — specifically at the sites where a developer had to fight a fire. Every guard clause, null-default, and reject branch in the submission path was written because a real claim failed in production and someone fixed it now, against the courier, under the one condition that guarantees correctness: it had to actually work. A doc records what someone believed the rule was on a calm afternoon. The code records what the courier forced to be true. And the commit that carries the fix carries the why — the incident, the ticket — which a doc never captures.

Operating principle — when you fix the fire, encode the rule. The same commit that stops the bleed declares the ClaimPolicy precondition. The validator and the submitter are then born from the identical fix, so no doc exists that anyone must remember to update, and drift becomes structurally impossible.

One honest caveat: not every scar is a rule. Some fires are put out with hacks — silent fallbacks, client-GUID special-cases, magic-number rescues. So "the truth is in the code" locates where courier reality surfaces; lifting it into a ClaimPolicy is also the moment you separate genuine courier rules from panic-era workarounds to pay down.

Worked example: Royal Mail already has the validator

The strongest proof is that the "claim validator agent" people ask for is already running — as ClaimValidationService.ValidateClaimAsync inside the Royal Mail submitter (backend/RoyalMail.Claim.Submitter/Services/ClaimValidationService.cs). Nobody designed it as a validator. It accreted, one fire at a time:

Fire-forged rule Source Forged by
A "Damaged" claim with no non-.eml Other Evidence is rejected (InsufficientEvidence, id 15) instead of looping forever ClaimValidationService.cs:125-144 b8e1e2721reject Damaged claims with no Other Evidence
Null postage → £3.99, clamped to £50 (a null ShippingCost used to submit as £50 because null < 50 is false) Page4Submitter.cs:250-255 bc4547e18default null postage to £3.99 instead of £50 cap
Null / unmapped ServiceCode & ContentType must not throw on edit (.Single() crashed the edit form for 107 of 22,448 claims) RoyalMailClaimForm.cs:156-181 22fd54f2c + a8910a4f2tolerate… / guard blank codes
A non-empty ServiceCode must exist in RoyalMail_ClaimServiceCode where Legacy == false ClaimValidationService.cs:92-106 pre-existing scar
No submittable evidence at all → reject ProofOfPurchaseNotProvided (never clears RM's final page) ClaimValidationService.cs:108-123 pre-existing scar
May not file until 7 UK working days (weekends + bank holidays excluded) after collection ClaimValidationService.cs:52-64 pre-existing scar
BUYBACK / tracking-not-found must carry a delivery address, else TrackingNotFoundException (don't type nulls into RM) Page4Submitter.cs:108-125 pre-existing scar
Back Market BUYBACK rows arrive wholesaleValue = 0; cost basis falls back to retail — never persist a £0 basis (value-gated couriers would silently drop it) CreateBackMarketClaimsActivity.cs:337-344 pre-existing scar

But here is the catch — and it is Patrick's own caveat turned inside out. This validator runs only at submit time, only inside the Royal Mail submitter. The frontend claim form and the bulk ingester don't call it, so a manually-entered or freshly-ingested claim sails past all of it and lands in "New" — exactly the noise everyone is trying to kill. There is no separate "required fields" list for RM either; the de-facto schema is just the [required] attributes on RoyalMailClaimForm and the guard clauses above. In other words: the rules exist, they're battle-tested, and they're trapped at one call site. That is the "no single brain" problem, made concrete.

The unified-agent move is therefore not to write RM's rules — they're written — but to lift them into ClaimPolicy.Validate(claim) and have all three entry points call the identical code: the pre-submission gate, the submitter, and the FE form (so a customer is told at entry that a Damage claim needs evidence). Same code, three callers, zero drift. Dominic's holding-area-plus-grace-period is then just "run ClaimPolicy.Validate after the grace window; release on pass, reject/MoreInfo on fail."

Build vs. reuse

The scope is deliberately narrow. Only one box is genuinely new.

Piece Effort
ClaimPolicyResolver + ClaimPolicy model Build — the real work, but a read-only facade over existing sources
ClaimPolicy.Validate(claim) preconditions Lift, don't write — extract the fire-forged checks already in ClaimValidationService / Page*Submitter / form attributes
Migrate call sites to ask the resolver Refactor — mechanical, incremental, one courier at a time
Wire the FE form + ingester to the shared Validate Refactor — closes the manual-entry gap Patrick flagged
Dispatcher, ingestion, classification Reuse as-is
Per-claim lifecycle + transitions + submission Reuse as-is
Resolve the duplicate ClaimTransition.cs Cleanup — prerequisite hygiene

The single highest-leverage move is the resolver. It turns "five subsystems each know a slice" into "the agent knows the rules," without rewriting the execution engine that already works.

Two things to decide first

The agent's authority ceiling is a policy decision, not a code detail. Today Credited is hard-blocked for agent auto-execution and Accepted needs an explicit [ALLOW_ACCEPTED] marker. A unified agent makes widening that easier — so the resolver should treat those ceilings as first-class, auditable fields, never incidental flags.

The code-vs-DB split exists for a reason. EnabledCclIds is deliberately a code gate — a rollout safety brake that configuration cannot override. If the resolver flattens everything into DB config, that brake is lost. The resolver should read both tiers but must not collapse them.

Summary

You are not building a claim agent from nothing. You already have the knowledge sources, the triggers, and a durable execution engine that carries claims to credit — the Colissimo path (TECH-923, Trevor Daniel) proves the full New-to-Credited distance is already achievable per courier. You even already have the validator everyone is asking for: it's ClaimValidationService, forged fire by fire in the Royal Mail submitter — just trapped at one call site. The rules are not missing; they are battle-tested and scattered. What is missing is the one component that lets the system answer "what are the rules for this claim, and what am I allowed to do?" in a single place, sourced from where the truth actually lives — the code a developer wrote while fighting a fire. Build the ClaimPolicyResolver, lift the fire-forged checks into ClaimPolicy.Validate, route every entry point and gate through it, and the fleet of per-courier workflows becomes a single rule-driven agent.