Building in the Fog, Part 5: Ahmad Is Both
'2 halal, 1 vegetarian' breaks when one person has both restrictions. The fix wasn't a better counter — it was a structural redesign from anonymous counts to named participants, and from warnings to auto-substitution.
Part 5 of "Building in the Fog" — a series about building Ridgeline, a provisioning decision engine for Outward Bound Hong Kong. Part 4 covered the calorie shortfall — correct numbers that revealed the original menu provides half the calories teenagers need on an expedition.
The count that can't count
The first dietary system was simple. A scenario had a dietary mix: halal: 2, vegetarian: 1. Three people with restrictions out of 14. The calculator checked each ingredient against these counts and generated warnings: "Pork Burger in D1 is not halal — 2 people affected."
Then the hypothetical: what if one person is both halal and vegetarian?
Enter them as halal: 1, vegetarian: 1? That's two people with restrictions. But it's one person. The group size is 14, not 15. The provision list scales for 14 — but the system thinks one person needs a halal swap and a different person needs a vegetarian swap, when both accommodations need to satisfy the same plate.
Anonymous counts can't represent intersecting dietary needs. A person who is halal and vegetarian needs food that satisfies both constraints simultaneously. The system needs to know that these flags belong to one person — which means it needs to know about people.
From Counts to Names
The redesign happened in two phases on February 25th, planned in a design doc before any code (docs/plans/per-person-dietary-profiles.md).
Phase 1 (5c7f942): Replace anonymous counts with named participants. A new scenario_participants table:
create table public.scenario_participants (
id uuid primary key default gen_random_uuid(),
scenario_id uuid not null references public.scenarios(id) on delete cascade,
name text not null,
dietary_flags text[] not null default '{}',
created_at timestamptz not null default now()
);
Instead of halal: 2, vegetarian: 1, you get participant cards: Ahmad (halal, vegetarian), Fatima (halal), 12 others unrestricted. The old scenario_dietary_mix table was preserved for backward compatibility — the calculator checks participants first, falls back to the old counts if none exist.
The warnings got personal. Instead of "2 people affected," the system now says "Pork Burger in D1 is not halal — affects Ahmad, Fatima." Names change the conversation. A supply officer reading "2 affected" might gloss over it. "Affects Ahmad" means a specific kid doesn't eat dinner.
The Sesame Milk Problem
While seeding allergen data for the Big 9 (cff3b35), a category collision surfaced. The Hart Limited import had introduced "Sesame Milk" as an ingredient. The ingredient_category enum included dairy. The dietary_flags included dairy_free.
Sesame Milk is a beverage (category: beverage), not dairy (category). It is flagged dairy_free because it contains no milk. But if someone had used dairy as both a category and a dietary flag, the system would flag Sesame Milk as a dairy product and dairy-free — a contradiction.
The fix was a clear separation documented in ADR-004:
ingredient_category= provisioning role (how you buy, store, and transport it)dietary_flags= allergen and dietary properties (who can eat it)
These are different questions. "Where does this live in the supply room?" is not the same as "Can Ahmad eat this?"
The same audit surfaced other flag errors. Coconut Milk had nut_free — but the FDA classifies coconut as a tree nut allergen. Sesame Milk had sesame_free, which is backwards. Egg noodles had egg_free. Each one a quiet data bug that only matters when a kid has an allergy.
Vegan Implies Vegetarian
Another assumption hiding in the data: the vegetarian flag wasn't on vegan items.
Tofu was flagged vegan but not vegetarian. TVP was vegan but not vegetarian. If Ahmad is vegetarian, the system wouldn't offer him tofu — because tofu didn't have the vegetarian flag, even though every vegan food is by definition vegetarian.
The fix (3f975db) had two parts. A migration backfilled vegetarian onto all vegan ingredients (and onto dairy/egg items like Cheese Powder and Milk Powder that are vegetarian but not vegan). And the calculator got a hierarchy map:
const DIETARY_IMPLIES: Record<string, string[]> = {
vegan: ['vegetarian'],
}
// Expand ingredient flags with implied flags
const expandedFlags = new Set(ingredient.dietary_flags)
for (const flag of ingredient.dietary_flags) {
const implied = DIETARY_IMPLIES[flag]
if (implied) {
for (const f of implied) expandedFlags.add(f)
}
}
Belt and suspenders — the data is correct, and the code handles the implication even if someone forgets to add vegetarian to a new vegan ingredient.
Phase 2: Auto-Substitution
Warnings are useful. But a supply officer reading "Pork Burger is not halal — affects Ahmad" still has to figure out what to buy instead. Phase 2 (96aa165) made the system do that work.
A new ingredient_alternatives table links ingredients to their substitutes, ordered by priority:
Pork Burger → Halal Sausage (priority 0), TVP (priority 1), Tofu (priority 2)
Ham → Halal Sausage (priority 0), Tempeh (priority 1), Tofu (priority 2)
Luncheon Meat → Halal Sausage (priority 0), Lentils (priority 1)
The calculator partitions participants into groups. For each per_person item, it checks who can eat the original and who needs a substitute. The computeSubstitutionGroups function:
for (const p of participants) {
const unsatisfied = p.dietary_flags.filter(f => !ingredientFlags.has(f))
if (unsatisfied.length === 0) {
mainNames.push(p.name)
continue
}
// Find best alternative satisfying ALL of this participant's flags
const best = findBestAlternative(alternatives, p.dietary_flags)
if (best) {
subMap.set(best.alternative_ingredient_id, { ... })
} else {
noAlternativeNames.push(p.name)
mainNames.push(p.name) // better to have food than no food
}
}
Ahmad is both halal and vegetarian. The system finds the highest-priority alternative whose flags satisfy both requirements. Halal Sausage (priority 0) is halal but not vegetarian — skip. TVP (priority 1) is vegan, which implies vegetarian, and it's halal. Ahmad gets TVP.
Fatima is halal only. Halal Sausage (priority 0) is halal. Fatima gets Halal Sausage.
The provision list now shows: 12 Pork Burgers (main group), 1 Halal Sausage (for Fatima), 1 TVP (for Ahmad). Instead of 14 Pork Burgers and a warning nobody acts on.
The Structural Change
This is the episode where the app's architecture shifted. The original model was: quantities in, quantities out. The dietary system was a post-hoc check — calculate everything, then warn about problems.
The new model is: quantities in, people in, adjusted quantities out. The calculator now partitions and substitutes at computation time, not display time. This touched every layer:
- Database: new tables for participants and alternatives
- Calculator: substitution logic in the main provision loop
- Workbench UI: participant cards instead of count dropdowns, substitution items indented under originals
- New scenario form: participant entry instead of dietary mix
- Clone route: copies participants alongside other scenario data
- CSV export: "Substitutes For" column
Eleven new tests. The plan doc listed every file that would change and every test that was needed. The implementation followed it almost exactly — which is rare enough to be worth noting.1
Next: Part 6 — Field Ready, in which the software has to work on a phone at 320px in a warehouse where supply officers don't think in software terms.
Footnotes
-
The
scenario_dietary_mixtable was never deleted. The calculator checks for participants first and falls back to the old counts. This is deliberate — old scenarios created before the redesign still work. Migration pressure is low because the system is pre-launch. ↩