By Isaac Muthui, CEO, Tamalaki Business Network Ltd. Originally published at factoraerp.com.
In a lot of factory systems, allergens live in exactly one place: a free-text field on the label artwork. The recipe does not know about them, the line does not know about them, and the scheduler certainly does not.
That works until a supplier reformulates a seasoning blend to include milk powder, or a plain product is squeezed onto a line straight after a peanut run. Then the question "which batches could contain an allergen that is not on the label?" becomes a manual investigation across spreadsheets, paper changeover logs and WhatsApp messages.
That is a data modelling problem before it is a hygiene problem.
The standards are clear about what has to be true. The Codex General Principles of Food Hygiene (CXC 1-1969), section 13.2.7, says allergens should be identified in raw materials, other ingredients and products, that an allergen management system should cover receipt, processing and storage, and that cross-contact should be prevented by separation "either physically or by time (with effective cleaning between foods with different allergen profiles)". The Codex code of practice on food allergen management (CXC 80-2020) adds that heat treatments that kill pathogens generally do not destroy allergenic proteins. And the Codex labelling standard (CXS 1-1985, section 4.2.1.4, quoted in this review) lists the foods that must always be declared.
"Identified in raw materials", "allergen profile", "separation by time" and "effective cleaning" are all things a schema can enforce. Here is how I would model it.
The entities
Allergen (GLUTEN | CRUSTACEA | EGG | FISH | PEANUT | SOY | MILK | TREE_NUT | SULPHITE | ...)
Material ── MaterialAllergen (CONTAINS | MAY_CONTAIN, source: supplier spec version)
RecipeVersion ── RecipeLine → Material
└─ derived: allergen_profile (set of Allergen)
LabelVersion ── declared_allergens (must equal recipe profile ∪ precautionary)
ProductionLine ── LineAllergenState (allergens possibly present since last verified clean)
ProductionOrder → RecipeVersion, LabelVersion, ProductionLine
Changeover (from_order, to_order, type, cleaned_by, verified_by, swab_result)
ReworkLot → allowed only into recipes whose profile ⊇ rework profile
NonConformance ── BatchExposure → Batch (hold / release)
Allergens on materials, versioned by supplier spec
CREATE TABLE allergen (
code TEXT PRIMARY KEY, -- 'MILK', 'PEANUT', 'GLUTEN', ...
name TEXT NOT NULL
);
CREATE TABLE supplier_spec (
id BIGSERIAL PRIMARY KEY,
material_id BIGINT NOT NULL REFERENCES material(id),
supplier_id BIGINT NOT NULL REFERENCES supplier(id),
version INT NOT NULL,
approved_at TIMESTAMPTZ,
UNIQUE (material_id, supplier_id, version)
);
CREATE TABLE material_allergen (
spec_id BIGINT NOT NULL REFERENCES supplier_spec(id),
allergen TEXT NOT NULL REFERENCES allergen(code),
status TEXT NOT NULL CHECK (status IN ('CONTAINS','MAY_CONTAIN')),
PRIMARY KEY (spec_id, allergen)
);
The important design choice: allergens hang off the supplier specification version, not the material master. When a supplier changes a formulation, you approve a new spec version, and every recipe that uses that material gets a recomputed profile. Intake can then check the delivered bag against the approved spec, which is what Codex means when it mentions inspecting incoming materials for declared allergens.
Derive the recipe profile; never type it
CREATE VIEW recipe_allergen_profile AS
SELECT rl.recipe_version_id,
ma.allergen,
MIN(ma.status) AS status -- 'CONTAINS' sorts before 'MAY_CONTAIN'
FROM recipe_line rl
JOIN active_supplier_spec s ON s.material_id = rl.material_id
JOIN material_allergen ma ON ma.spec_id = s.spec_id
GROUP BY rl.recipe_version_id, ma.allergen;
If a human types the allergen list for a product, it will drift from the recipe. Derive it.
The label check is a set comparison
A pack run should not start unless the label version declares at least every allergen the recipe contains:
def label_ok(recipe_profile: set[str], label_declared: set[str]) -> bool:
# Every CONTAINS allergen in the recipe must be declared on the label.
return recipe_profile.issubset(label_declared)
Make this a hard gate on releasing a production order to packing. A recipe change that adds an allergen then blocks packing until a matching label version is approved, which closes the most common gap: the label lagging the recipe.
Line allergen state and the changeover gate
The line needs memory. Track which allergens may be present on it since the last verified clean:
def can_start(order, line_state: set[str], last_changeover) -> tuple[bool, str]:
incoming = order.recipe_profile # allergens the next product contains
residue = line_state - incoming # allergens on the line the next product must not contain
if not residue:
return True, "no allergen changeover needed"
if last_changeover is None or last_changeover.type != "ALLERGEN_FULL_CLEAN":
return False, f"allergen changeover required for {sorted(residue)}"
if last_changeover.verified_by is None or last_changeover.verified_by == last_changeover.cleaned_by:
return False, "clean must be verified by a second person"
if last_changeover.swab_required and last_changeover.swab_result != "PASS":
return False, "allergen swab not passed"
return True, "verified allergen clean on record"
After a verified full clean, reset line_state to the empty set; after each order, union in that order's profile. Codex section 11.1.1 mentions protein and allergen test swabs as one way to verify cleaning, so swab_required is a per-line, risk-based setting rather than a global flag.
The same state powers the scheduler: sort orders on a shared line so allergen-free products run first, which is the "separation by time" Codex describes and the cheapest control a plant has.
Rework is a subset rule
def rework_allowed(rework_profile: set[str], target_recipe_profile: set[str]) -> bool:
return rework_profile.issubset(target_recipe_profile)
Rework lots carry the profile of the batch they came from. If the target recipe does not already contain every allergen in the rework, the system refuses the issue.
Failures become batch exposure
When something fails (a mislabelled delivery, a skipped clean, a failed swab, a complaint), open a non-conformance and compute exposure: every batch produced on that line since the last verified clean, plus every batch that consumed the suspect material lot. Put those lots on QA hold in one transaction. That is the query a recall depends on, and it is only fast if the links above exist.
What I would not do
- No free-text allergen fields on products or labels. Use codes.
- No global "may contain" default. Codex frames precautionary labelling as informing consumers where cross-contact cannot be prevented despite good controls. Store it as an explicit, justified decision per product and line.
- No silent spec updates. A supplier spec change is a versioned approval that triggers recomputation and review.
Wrapping up
The floor version of this, for production and QA teams rather than developers, is an eight-step checklist: allergen matrix, intake checks, segregated storage, scheduling, verified changeover cleans, rework rules, label checks and training. It is on the FactoraERP blog: Allergen control checklist for food factories.
FactoraERP, which my company builds, links production orders to recipes and BOM versions, issued lots and batch records, with QA holds and non-conformance records. If you have modelled allergens differently in your own system, I would like to hear how you handled line state.
Top comments (0)