Engineering reference: the allergen 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 three-state model, and how roll-ups are stored.
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 |
|---|---|---|---|
al-ingest | S3 put on the specs prefix | Extracts declarations for all 14 allergens; compares against the stored version; versions it | 180s / 1024 MB |
al-rollup | DynamoDB stream on ingredients | Recurses the recipe tree; recomputes every affected dish’s three-state declaration | 120s / 1024 MB |
al-watch | API and EventBridge daily | Records substitutions, computes the delta, stops affected dishes, emits the pre-service briefing | 30s / 512 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 |
|---|---|---|
al-ingest-role | s3:GetObject, bedrock:InvokeModel, dynamodb:PutItem | The specs prefix; one model id; ingredients |
al-rollup-role | dynamodb:Query, dynamodb:UpdateItem | Both tables |
al-watch-role | dynamodb:Query, dynamodb:UpdateItem, ses:SendEmail | Both tables; one verified identity |
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: ingredients
PK ingredient_id S stock_base_brown
SK spec_version S 2026-06-14#sha256-prefix
supplier S
product_code S unchanged across reformulations
declarations M {gluten: contains, mustard: may_contain,
celery: unknown, ...} — all 14, always
spec_dated S the date on the document, not on receipt
spec_key S s3 key of the original
superseded_at S set when a newer version arrives
is_compound BOOL and if so, its own recipe id
Every version is kept. Comparison against the previous version is the
entire supplier-change mechanism, and it needs both to exist.
Table: dishes
PK dish_id S
recipe_version S
rolled_up M {gluten: contains, celery: unknown, ...}
unknown_count N surfaced at the top of every lookup
unknown_because L [{allergen, ingredient, reason}]
rolled_up_at S
kitchen_note S shared fryer, flour in the air — per site
stopped BOOL set by a substitution that added an allergen
stopped_reason S
substitutions L [{date, replaced, with, delta, by}]
`unknown_because` names the ingredient and the reason, so the unknown
list is a work list rather than a warning.
Inbound and outbound
- Specifications arrive by email or upload into S3 and are versioned by content hash. A re-sent identical document does not create a version.
- Every allergen is stored explicitly for every ingredient. There is no null and no absent key; a missing declaration is the string
unknown. - Substitution capture is two taps from a short list. Anything slower is not used during service, which means it does not exist.
- Roll-ups are computed on change and stored, so a lookup during service is a single read and never a recursion.
The model call
- One read per specification document. Extracting the declared state of all fourteen allergens.
- It must return unknown, not infer. The prompt is explicit that an allergen the document does not address is unknown, and inference is a failure.
- It never rolls up. Combining ingredient declarations into a dish is deterministic code, because the same inputs must always give the same answer.
- It never produces a verdict. Part 4 is entirely about why there is no safety output anywhere in this system.
- Structured specifications skip the model, which over time is the majority.
Things worth knowing before you build it
- Store unknown as a value, never as a missing key. A null renders as a blank and a blank reads as a confirmed negative.
- Keep every specification version. Change detection compares against the previous one, and overwriting removes the only mechanism that catches a reformulation.
- Expire old specifications into unknown. A three-year-old document may be accurate and there is no way to know.
- Compute roll-ups on change and store them. A recursion at lookup time is slow at exactly the moment somebody is waiting at a table.
- Do not add a safe indicator, a green tick, or a suitability score, however often it is requested. Cross-contamination and severity are not in this data.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts