Engineering reference: the packing slip checker 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 offline sync, and where the model is used.
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
- 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 |
|---|---|---|---|
ps-match | S3 put on the slips prefix | Reads the slip, matches it to a purchase order, builds the check list | 120s / 1024 MB |
ps-receive | API, from the device | Accepts counts and photographs, generates the annotation wording, records the receipt | 30s / 1024 MB |
ps-aggregate | EventBridge, weekly | Groups discrepancies by supplier, product and unit mapping; computes acceptance rates | 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 |
|---|---|---|
ps-match-role | s3:GetObject, bedrock:InvokeModel, dynamodb:PutItem, dynamodb:Query | The slips prefix; one model id; receipts; read-only on purchase orders |
ps-receive-role | s3:PutObject, dynamodb:UpdateItem, ses:SendEmail | The photographs prefix; receipts and discrepancies; one verified identity |
ps-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: receipts
PK po_number S
SK line_no N
sku S
ordered_qty N
slip_qty N what the packing slip claimed
counted_qty N null if not counted
status S counted | accepted | substituted
unit_mapping S ’box of 12’ — the mapping in force at the time
photos L s3 keys, wide and close
received_at S
annotated BOOL true if written on the note before signing
received_by S a named person
`status` distinguishes counted from accepted. A single ’received’ value
cannot answer the first question anybody asks two months later.
Table: discrepancies
PK supplier S
SK found_at#po#line S
kind S short | over | substituted | wrong_item
sku S
expected N
found N
value_pence N
unit_mapping S copied, so a later mapping change cannot rewrite history
notified_at S same day, or the reason it was not
resolved_at S credit or redelivery
resolution S
Over-shipments are the same record type as shortages, so both appear
in the same pattern analysis. They usually share a cause.
Inbound and outbound
- Slips arrive by email into an S3 prefix. Suppliers who send structured files are parsed directly; only unstructured slips reach the model.
- The device downloads the check list when the match completes, so nothing at the door needs a network round trip.
- Counts and photographs queue locally and sync when signal returns. The sync is idempotent on a device-generated id.
- The receipt record feeds the invoice match, so a held line cannot be quietly paid. That integration is the main one worth doing properly.
The model call
- One read per unstructured packing slip. Extracting product codes, quantities and units into lines.
- It never decides the check list. That is three rules: any difference, high value or history, plus the rotation.
- It never interprets a substitution. Whether a substituted product is acceptable is a judgement recorded against a person’s name.
- The annotation wording is a template, not generated. A sentence written on a legal document is not a place for variation.
- Structured slips skip the model entirely, which over time is most of them and most of the bill.
Things worth knowing before you build it
- Record counted and accepted as different statuses. It is one field and it answers the question every dispute starts with.
- Copy the unit mapping onto the discrepancy record. A mapping corrected next month must not retroactively make old discrepancies disappear.
- Make the device work offline before anything else. A receiving app that needs signal in a loading bay is a receiving app that records everything as correct.
- Generate the annotation wording and show it before the signature step, not after. The order is the whole point.
- Put the acceptance rate on the discrepancy report. Without it, the least-checked supplier always looks like the best one.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts