Feature: ingredient-level allergen + dietary tagging with modifier-aware propagation #10

Open
opened 2026-05-12 06:09:27 +00:00 by padreug · 0 comments
Owner

Blocked by #3 (inventory / recipe BOM). Graceful fallback works before #3 — see Pathway notes.

Background

The current data model stores menu_items.allergens as a flat array on the item — coarse and misleading. Two real cases it gets wrong:

  1. Modifier-conditional: a Latte is only dairy if you pick Whole milk. With Oat/Almond it isn't, but the menu item still wears the "dairy" badge.
  2. Inherent vs. removable: a dish may contain gluten by its nature (mole, soy sauce in a wok dish), where the kitchen sometimes can substitute and sometimes can't.

Industry consensus (Supy, Kitchen Cut, Apicbase, Lavu, Toast, Oracle Simphony, CertiStar, Menutech) converges on the same architecture: allergens live on ingredients, not on menu items, and a recipe BOM propagates them to dishes — including through modifiers, which contribute their own recipe lines. This issue tracks adopting that pattern.

The word "allergen" also carries a judgement. We can split the field by intent so the same plumbing carries neutral disclosure, regulatory disclosure, and positive dietary claims.

Goals

  • Restaurants tag allergens / contains-flags once per ingredient, not N times per dish.
  • A modifier swap (Whole → Oat) recomputes the dish's contains-set automatically.
  • Operators who don't (yet) track recipes still get the current item-level field as a fallback — Pathway D.
  • The customer surface can distinguish:
    • Contains by-default (it's in the dish as currently configured)
    • May contain (cross-contact, voluntary disclosure)
    • Removable on request (kitchen can prep without it)
  • The wording is neutral. We're disclosing ingredients, not making moral claims.

Pathway D — Hybrid, item-level fallback + computed when recipe exists

The cheapest pathway that solves both cases without forcing every operator to author recipes. Plays naturally with the tiered-modes work in #2:

Mode Behavior
bar menu_items.contains[] literal (today's behavior), no recipe expansion
bistro Recipe-driven: contains = union over materials.contains_tags for the current selection; manual field still readable as a fallback for items without a recipe
full Same as bistro plus cross-contact (may_contain) inference from co-located materials / shared prep stations (#4 territory)

Data model (sketch — finalize alongside #3)

Adds to the materials table from #3:

ALTER TABLE materials
  ADD COLUMN contains_tags    TEXT,  -- JSON array: dairy, gluten, nuts, eggs, shellfish, sesame, soy, …
  ADD COLUMN may_contain_tags TEXT,  -- JSON array: cross-contact / voluntary disclosure
  ADD COLUMN dietary_tags     TEXT;  -- JSON array: vegan, halal, kosher, gluten_free (positive claims)

Replace / split menu_items.allergens (operator-facing):

-- Legacy: menu_items.allergens TEXT (JSON array)
-- New (manual fallback when no recipe exists):
ALTER TABLE menu_items
  ADD COLUMN contains    TEXT,  -- JSON array, neutral disclosure
  ADD COLUMN may_contain TEXT,  -- JSON array, voluntary cross-contact
  ADD COLUMN dietary     TEXT;  -- JSON array, positive claims (vegan, kosher, …)

(dietary already exists in spirit — this just splits the current allergens field along the contains / may_contain axis.)

Computation rule (server-side, exposed via the existing /menu endpoint)

For a menu item, the effective sets are:

if recipe(item) exists:
    contains    = union(material.contains_tags    for material in current_selection)
    may_contain = union(material.may_contain_tags for material in current_selection)
    dietary     = item.dietary  ∪  intersect(material.dietary_tags for material in current_selection)
else:
    contains    = item.contains
    may_contain = item.may_contain
    dietary     = item.dietary

current_selection defaults to "all is_default=true modifiers + base recipe lines". The webapp's ModifierSelector recomputes the active set on every selection change.

Modifier-conditional ("Latte+Whole milk")

Falls out for free from #3's "modifiers get their own recipe_lines":

  • Whole milk material has contains_tags=["dairy"]
  • Oat milk material has contains_tags=[], dietary_tags=["vegan"]
  • Selecting Oat swaps the recipe line → dish's contains recomputes → no "dairy" badge

Removable-on-request

Operator flags a modifier_groups.allows_omission BOOL on groups where "skip this entirely" is valid. Combined with the recipe expansion, omitting a removable group drops its materials from the union. UX surfaces this as yellow in the traffic-light pattern (see UX section).

UX (customer surface)

Traffic-light pattern — established in the industry (CertiStar is the canonical example, but the pattern is widespread):

Color Meaning Driver
Green Safe as-currently-configured tag is not in computed contains
Yellow Becomes safe with a modification tag is in contains but only via a removable / swappable selection
Red Tag is in contains and no modifier can change that tag is in contains from a non-removable recipe line

In the menu card / item page, each contains-tag pill renders in its computed color. Hovering / tapping shows why ("Dairy comes from Whole milk — swap to Oat").

The may_contain set renders as a separate, dimmer badge ("⚠ may contain shellfish — shared fryer").

Stretch — customer allergen profile + filter (separate issue once D ships)

Customer-side: store { allergies: [...] } in webapp local settings, color the entire menu by that profile, hide red items unless explicitly opted into.

This is a UX layer on top of D, not a competing model. Filing it now as a stretch note so it doesn't get forgotten when D lands.

Naming / framing (decided here)

  • The word "allergens" is replaced by contains (neutral disclosure) and may_contain (voluntary).
  • Positive claims keep the dietary name.
  • This is operator-facing too — the CMS field labels say "Contains" not "Allergens."
  • The EU-14 regulatory list still maps cleanly to contains for compliance.

Acceptance criteria

  • materials.{contains_tags, may_contain_tags, dietary_tags} columns added in the migration that ships with #3.
  • menu_items.contains + menu_items.may_contain columns; existing menu_items.allergens migrated → contains, retired.
  • menu_items.dietary stays as-is (already exists).
  • GET /restaurants/{id}/menu returns the computed contains / may_contain / dietary sets per item, plus the raw recipe expansion so the webapp can recompute on modifier change.
  • Webapp ModifierSelector recomputes the active contains-set live as modifiers toggle.
  • Webapp ItemPage renders traffic-light pills (green / yellow / red) per contains-tag.
  • CMS:
    • inventory panel (from #3) gains contains_tags, may_contain_tags, dietary_tags editors on each material
    • item dialog: when a recipe exists, contains/may_contain are read-only (computed) with a note pointing at the materials; when no recipe exists, the legacy manual fields are editable
    • tier gate (#2): in bar mode, hide the materials editor and treat the manual fields as the only source
  • docs/data-model gains a "Contains / may_contain / dietary" section explaining computation, fallback, and naming.
  • docs/webapp-integration documents the computed-vs-raw fields surfaced over REST.

Out of scope

  • Full per-customer allergen-profile filter UX — separate stretch issue, filed once D ships and the data is high-fidelity enough to drive it.
  • Cross-contact inference from shared prep stations — depends on #4's kitchen workflow surfaces; could land as a follow-up.
  • AI-assisted ingredient inference ("our nachos probably contain dairy") — out of scope; we encode what the operator actually declares.
  • Allergen disclosure for foreign menus retrieved via NIP-99 from other restaurants — covered by the same contains_tags riding in the kind-30402 payload, but the multi-restaurant aggregator UX (#8) is where that surfaces.

See also

  • #3 — inventory + recipe BOM (parent; this issue is blocked by #3 for the C-path components, has a fallback for the A-path)
  • #2 — tiered operator modes (which tier sees which fidelity)
  • #4 — kitchen workflow (cross-contact inference, removable-on-request prep instructions)
  • #8 — festival aggregator (how foreign-menu contains-tags ride over Nostr)

References

**Blocked by #3** (inventory / recipe BOM). Graceful fallback works before #3 — see Pathway notes. ## Background The current data model stores `menu_items.allergens` as a flat array on the item — coarse and misleading. Two real cases it gets wrong: 1. **Modifier-conditional**: a Latte is only dairy if you pick Whole milk. With Oat/Almond it isn't, but the menu item still wears the "dairy" badge. 2. **Inherent vs. removable**: a dish may contain gluten by its nature (mole, soy sauce in a wok dish), where the kitchen sometimes can substitute and sometimes can't. Industry consensus (Supy, Kitchen Cut, Apicbase, Lavu, Toast, Oracle Simphony, CertiStar, Menutech) converges on the same architecture: **allergens live on ingredients, not on menu items**, and a recipe BOM propagates them to dishes — including through modifiers, which contribute their own recipe lines. This issue tracks adopting that pattern. The word "allergen" also carries a judgement. We can split the field by intent so the same plumbing carries neutral disclosure, regulatory disclosure, and positive dietary claims. ## Goals - Restaurants tag allergens / contains-flags **once per ingredient**, not N times per dish. - A modifier swap (Whole → Oat) **recomputes** the dish's contains-set automatically. - Operators who don't (yet) track recipes still get the current item-level field as a fallback — Pathway D. - The customer surface can distinguish: - **Contains** by-default (it's in the dish as currently configured) - **May contain** (cross-contact, voluntary disclosure) - **Removable on request** (kitchen can prep without it) - The wording is neutral. We're disclosing ingredients, not making moral claims. ## Pathway D — Hybrid, item-level fallback + computed when recipe exists The cheapest pathway that solves both cases without forcing every operator to author recipes. Plays naturally with the tiered-modes work in #2: | Mode | Behavior | |---|---| | `bar` | `menu_items.contains[]` literal (today's behavior), no recipe expansion | | `bistro` | Recipe-driven: contains = union over `materials.contains_tags` for the current selection; manual field still readable as a fallback for items without a recipe | | `full` | Same as `bistro` plus cross-contact (`may_contain`) inference from co-located materials / shared prep stations (#4 territory) | ## Data model (sketch — finalize alongside #3) Adds to the `materials` table from #3: ```sql ALTER TABLE materials ADD COLUMN contains_tags TEXT, -- JSON array: dairy, gluten, nuts, eggs, shellfish, sesame, soy, … ADD COLUMN may_contain_tags TEXT, -- JSON array: cross-contact / voluntary disclosure ADD COLUMN dietary_tags TEXT; -- JSON array: vegan, halal, kosher, gluten_free (positive claims) ``` Replace / split `menu_items.allergens` (operator-facing): ```sql -- Legacy: menu_items.allergens TEXT (JSON array) -- New (manual fallback when no recipe exists): ALTER TABLE menu_items ADD COLUMN contains TEXT, -- JSON array, neutral disclosure ADD COLUMN may_contain TEXT, -- JSON array, voluntary cross-contact ADD COLUMN dietary TEXT; -- JSON array, positive claims (vegan, kosher, …) ``` (`dietary` already exists in spirit — this just splits the current `allergens` field along the contains / may_contain axis.) ### Computation rule (server-side, exposed via the existing `/menu` endpoint) For a menu item, the effective sets are: ``` if recipe(item) exists: contains = union(material.contains_tags for material in current_selection) may_contain = union(material.may_contain_tags for material in current_selection) dietary = item.dietary ∪ intersect(material.dietary_tags for material in current_selection) else: contains = item.contains may_contain = item.may_contain dietary = item.dietary ``` `current_selection` defaults to "all `is_default=true` modifiers + base recipe lines". The webapp's `ModifierSelector` recomputes the active set on every selection change. ### Modifier-conditional ("Latte+Whole milk") Falls out for free from #3's "modifiers get their own `recipe_lines`": - Whole milk material has `contains_tags=["dairy"]` - Oat milk material has `contains_tags=[]`, `dietary_tags=["vegan"]` - Selecting Oat swaps the recipe line → dish's `contains` recomputes → no "dairy" badge ### Removable-on-request Operator flags a `modifier_groups.allows_omission BOOL` on groups where "skip this entirely" is valid. Combined with the recipe expansion, omitting a removable group drops its materials from the union. UX surfaces this as **yellow** in the traffic-light pattern (see UX section). ## UX (customer surface) Traffic-light pattern — established in the industry (CertiStar is the canonical example, but the pattern is widespread): | Color | Meaning | Driver | |---|---|---| | **Green** | Safe as-currently-configured | tag is not in computed `contains` | | **Yellow** | Becomes safe with a modification | tag is in `contains` but only via a removable / swappable selection | | **Red** | Tag is in `contains` and no modifier can change that | tag is in `contains` from a non-removable recipe line | In the menu card / item page, each contains-tag pill renders in its computed color. Hovering / tapping shows *why* ("Dairy comes from Whole milk — swap to Oat"). The `may_contain` set renders as a separate, dimmer badge ("⚠ may contain shellfish — shared fryer"). ## Stretch — customer allergen profile + filter (separate issue once D ships) Customer-side: store `{ allergies: [...] }` in webapp local settings, color the entire menu by that profile, hide red items unless explicitly opted into. This is a UX layer on top of D, not a competing model. Filing it now as a stretch note so it doesn't get forgotten when D lands. ## Naming / framing (decided here) - The word **"allergens"** is replaced by **`contains`** (neutral disclosure) and **`may_contain`** (voluntary). - Positive claims keep the **`dietary`** name. - This is operator-facing too — the CMS field labels say "Contains" not "Allergens." - The EU-14 regulatory list still maps cleanly to `contains` for compliance. ## Acceptance criteria - [ ] `materials.{contains_tags, may_contain_tags, dietary_tags}` columns added in the migration that ships with #3. - [ ] `menu_items.contains` + `menu_items.may_contain` columns; existing `menu_items.allergens` migrated → `contains`, retired. - [ ] `menu_items.dietary` stays as-is (already exists). - [ ] `GET /restaurants/{id}/menu` returns the **computed** contains / may_contain / dietary sets per item, plus the raw recipe expansion so the webapp can recompute on modifier change. - [ ] Webapp `ModifierSelector` recomputes the active contains-set live as modifiers toggle. - [ ] Webapp ItemPage renders traffic-light pills (green / yellow / red) per contains-tag. - [ ] CMS: - inventory panel (from #3) gains `contains_tags`, `may_contain_tags`, `dietary_tags` editors on each material - item dialog: when a recipe exists, contains/may_contain are read-only (computed) with a note pointing at the materials; when no recipe exists, the legacy manual fields are editable - tier gate (#2): in `bar` mode, hide the materials editor and treat the manual fields as the only source - [ ] [[docs/data-model]] gains a "Contains / may_contain / dietary" section explaining computation, fallback, and naming. - [ ] [[docs/webapp-integration]] documents the computed-vs-raw fields surfaced over REST. ## Out of scope - Full per-customer allergen-profile filter UX — separate stretch issue, filed once D ships and the data is high-fidelity enough to drive it. - Cross-contact inference from shared prep stations — depends on #4's kitchen workflow surfaces; could land as a follow-up. - AI-assisted ingredient inference ("our nachos probably contain dairy") — out of scope; we encode what the operator actually declares. - Allergen disclosure for foreign menus retrieved via NIP-99 from other restaurants — covered by the same `contains_tags` riding in the kind-30402 payload, but the multi-restaurant aggregator UX (#8) is where that surfaces. ## See also - #3 — inventory + recipe BOM (parent; this issue is **blocked by** #3 for the C-path components, has a fallback for the A-path) - #2 — tiered operator modes (which tier sees which fidelity) - #4 — kitchen workflow (cross-contact inference, removable-on-request prep instructions) - #8 — festival aggregator (how foreign-menu contains-tags ride over Nostr) ## References - [Apicbase — Food Allergen Management Guide](https://get.apicbase.com/food-allergen-management-ultimate-guide/) - [Supy — Allergen tracking for multi-site restaurants](https://supy.io/blog/allergen-tracking-smart-compliance-for-multi-site-restaurants) - [Kitchen Cut — Allergen & nutrition labelling](https://kitchencut.com/allergen-nutrition-labelling/) - [CertiStar — Why static allergen menus are risky](https://certistar.com/yesterdays-allergen-menu-risky-restaurants-guests/) - [Toast — Restaurant allergy guide](https://pos.toasttab.com/blog/on-the-line/restaurant-allergy-guide) - [Menutech — EU 1169/2011 allergen guide](https://menutech.com/en/blog/legal-requirements/eu-11692011-guide-allergen-labelling-requirements) - Customer observation 2026-05-11 (Lightning Cafe Latte → "shows dairy regardless of milk choice").
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/restaurant#10
No description provided.