Engineering reference: the damaged goods claimer architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the carrier configuration, and the deadline computation.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Database
- Front-end & mobile
- People
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
dg-capture | API, from the device | Stores photographs, computes the deadline, generates the note wording and the hold label | 30s / 1024 MB |
dg-remind | EventBridge, daily | Runs the reminder ladder; records missed deadlines with their value | 60s / 512 MB |
dg-aggregate | EventBridge, weekly | Groups by carrier, route, packaging and supplier; computes rates per 100 consignments | 120s / 1024 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
dg-capture-role | s3:PutObject, dynamodb:PutItem, dynamodb:GetItem | The photographs prefix; claims; read-only on carriers |
dg-remind-role | dynamodb:Query, dynamodb:UpdateItem, ses:SendEmail | Claims; one verified identity |
dg-aggregate-role | dynamodb:Query | Read-only across both tables |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: claims
PK claim_ref S DG-2026-0184 — printed on the hold label
carrier S
service S the cap depends on this, not just the carrier
delivered_at S the clock starts here
found_at S equal to delivered_at, or concealed
concealed BOOL
deadline S a date, computed once
photos L 5 s3 keys, in the fixed order
note_annotated BOOL signed subject to the note?
goods_value N
cap_value N recoverable after the carrier’s cap
decision S file | log | filed | recovered | declined | missed
decision_reason S required when the decision is log
packaging_held BOOL cleared when the claim closes
`decision_reason` is what makes ’we did not claim for this’ an answer
rather than a gap, a year later.
Table: carriers
PK carrier#service S palletline#economy
visible_days N 7
concealed_days N 3
working_days BOOL true for most
cap_basis S per_kg | per_consignment | declared
cap_rate_pence N
terms_version S ’Nov 2025 conditions’
terms_checked_at S 2026-04-02
`terms_checked_at` is the field that stops this configuration quietly
going stale and producing late claims that look like process failures.
Inbound and outbound
- Capture happens on the device in a fixed five-photograph sequence, with a skip that records a reason rather than silently omitting a step.
- The deadline is computed at capture and stored as a date, using the carrier’s working-day setting and a public holiday calendar.
- The hold label prints immediately with the claim reference, because the packaging decision happens within the hour.
- Logged-but-not-filed records use the same table and appear in every aggregation. They are most of the volume.
The model call
- There is no model in this system. Deadlines are date arithmetic, the cap is a lookup, and the recommendation is four rules.
- The tempting use is assessing damage severity from the photographs. The carrier will form their own view, and an automated severity score adds nothing to the claim.
- The wrong use is drafting the claim narrative. A claim is a formal assertion about facts and its wording should be a template a person checked.
- Classifying damage type from a short picklist at capture is better than inferring it, and it is one tap.
- The cost page assumes none, which is why messaging and storage are the only variable bands.
Things worth knowing before you build it
- Store the deadline as a date, not a duration. Arithmetic under pressure over a bank holiday is how valid claims get filed late.
- Print the hold label at capture. The packaging decision happens within the hour and no email reaches anybody in time.
- Key the carrier configuration on carrier and service. Caps differ by service, and using the carrier alone produces a recoverable value that is wrong.
- Include logged-not-filed records in every aggregation. They are most of the volume and all of the frequency signal.
- Report damage per hundred consignments, never as a count. A count always blames whoever you ship with most.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts