Skip to content

Part 7 of 7 · Allergen checker series ~7 min read

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

The allergen checker drawn with AWS service namesThree boxes across the top outside the AWS account. Specifications arriving as a file or a scan. Recipes and substitutions. And The floor, performing lookups and receiving briefings. Inside the account, three groups. S3 holding specifications alongside an API for substitution capture. Three Lambda functions named ingest, rollup and watch. And two DynamoDB tables named ingredients and dishes. A note gives the region as us-east-1, one account, and states that specifications are versioned and unknown is a stored state rather than a null.AWS ACCOUNTSpecificationsfile or scanRecipesand substitutionsThe floorlookups and briefingsS3 + APIspecs,substitution captureLambda x3ingest, rollup, watchDynamoDB x2ingredients, dishesingroundsoutus-east-1. One account. Specifications versioned; unknown is a stored state, never a null.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
al-ingestS3 put on the specs prefixExtracts declarations for all 14 allergens; compares against the stored version; versions it180s / 1024 MB
al-rollupDynamoDB stream on ingredientsRecurses the recipe tree; recomputes every affected dish’s three-state declaration120s / 1024 MB
al-watchAPI and EventBridge dailyRecords substitutions, computes the delta, stops affected dishes, emits the pre-service briefing30s / 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

RoleAllowedOn
al-ingest-roles3:GetObject, bedrock:InvokeModel, dynamodb:PutItemThe specs prefix; one model id; ingredients
al-rollup-roledynamodb:Query, dynamodb:UpdateItemBoth tables
al-watch-roledynamodb:Query, dynamodb:UpdateItem, ses:SendEmailBoth 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