Recipe File · developer documentation
Canonical Recipe 5.0 — the recipe contract
The recipe contract a Recipe File carries: shape, bounds, cross-field rules, validation and migration. Rendered from the repository's own document; some of its references are to source files that are not published here.
Status: 5.0, the launch format (2026-10-18). v4 (FROZEN 2026-10-06,
committed 660553b on codex/canonical-recipe-schema) plus optional
additions: schemaMinor, language and attestation on the recipe;
author for a cook who signs, authorLinks and basedOn in provenance; and
the transformation kind cook_edit. Every v4 recipe is a valid 5.0 recipe; the
migration changes the version number only. Chef's backend still speaks v4
(§11). Where this document says "v4" of the structure, it holds for 5.0 too.
After launch the contract changes only by the rules in §16.
This document is the authoritative recipe contract; anything elsewhere that disagrees with it is out of date. Canonical Recipe v4 is the single authoritative recipe structure in Chef. Every feature that creates, imports, edits, stores, loads or shares a recipe reads or writes it, and every recipe-writing path ends in result → canonical v4 → validate → persist.
| Part | Where |
|---|---|
| Structural contract (JSON Schema 2020-12, bundled in the app) | MyRecipes/Resources/CanonicalRecipe.v5.schema.json |
| Structural contract v4 (kept byte-for-byte: the backend bundles it and its tests check it against the app's copy) | MyRecipes/Resources/CanonicalRecipe.v4.schema.json |
| Swift model | Models/CanonicalRecipe.swift |
validateRecipe — the one validator |
Models/RecipeValidator.swift |
| The only allowed repairs | Models/RecipeRepair.swift |
migrateRecipeToLatest — v1 → v2 → v3 → v4 → v5 |
Models/RecipeMigrator.swift |
| Persistence (load / save / backfill) | Persistence/CanonicalRecipeStore.swift, schemas V9–V10 in RecipeSchema.swift |
| Fixtures + manifest (shared by Swift and TypeScript) | MyRecipesTests/Fixtures/CanonicalRecipe/ |
| Tests | CanonicalRecipeContractTests, CanonicalRecipePersistenceTests |
| Shared TypeScript implementation (Edge Functions + fixture runner) | supabase/functions/_shared/canonicalRecipe.ts, re-exported by docs/canonical-recipe/reference/ |
deno run --allow-read docs/canonical-recipe/reference/check.ts
Sign-off. Can every legitimate Chef recipe be represented, migrated, validated, persisted and shared through one canonical structure without special-case recipe models or loss of information? — Yes (§13 has the evidence, §14 the known limits, none of which is a contract gap). v4 is frozen and ready for downstream Edge Function work.
1. Principles
- Absent means unknown; zero is a value. An optional field is present and
meaningful, or omitted.
"",[]andnullare never valid.0minutes of cooking and0calories are real values and round-trip as0. - The words are the truth; structure supports them.
quantityis what the cook reads, andamount/unitserve scaling and shopping. Steptextis the instruction, andtimersserve Cook Mode. Free-text quantities ("a pinch", "to taste") are never forced into numbers. - Computed facts are not stored. Allergens, free-from claims, step numbers, where a timer sits in the text, and display labels are derived by deterministic helpers.
- Recipe content and user state are separate (§5).
- One spelling per field. Older aliases are accepted only by the migrator.
- Strict in what Chef writes; safe with what it can't understand. Unknown fields are errors in a recipe of a version this build knows. A recipe of a newer minor version is opened, shown as far as it is understood and never rewritten; a newer major version is refused, never guessed at (§2a).
- The schema permits; Chef's generation guidance constrains. Several timers in one step are valid. New Chef recipes normally use one, and a warning lints it (§4).
2. Shape
Abbreviated from
valid/14-shared-kitchen-sink.json,
which uses every field. The smallest valid recipe has seven fields
(valid/12-incomplete-optional.json).
{
"schemaVersion": 5,
"id": "9e8d7c6b-5a49-4382-a716-05f4e3d2c1b0",
"title": "Halloumi & Chickpea Traybake with Herby Yoghurt",
"servings": 4, "yieldText": "Serves 4",
"totalTimeMinutes": 45, "prepTimeMinutes": 10, "cookTimeMinutes": 35,
"difficulty": "easy", "cuisine": "Middle Eastern", "course": "main",
"diets": ["vegetarian"], "tags": ["Traybake", "Halloumi", "Midweek"],
"equipment": [{ "name": "Oven", "appliance": "oven" }, { "name": "Large roasting tin" }],
"ingredientGroups": [{ "id": "traybake", "title": "For the traybake" }, …],
"ingredients": [
{ "id": "halloumi", "name": "halloumi", "quantity": "225g", "amount": 225, "unit": "g",
"preparation": "sliced", "groupId": "traybake", "substitutions": ["225g paneer, for a milder flavour"] },
{ "id": "mint", "name": "fresh mint", "quantity": "a small bunch", "groupId": "yoghurt", "optional": true }, …
],
"methodGroups": [{ "id": "make-traybake", "title": "Traybake" }, …],
"method": [
{ "id": "step-2", "groupId": "make-traybake",
"text": "Roast for 20 minutes, then lay the halloumi on top and roast for 10 minutes more until golden.",
"timers": [ { "id": "step-2-timer-1", "durationSeconds": 1200, "label": "Roast chickpeas" },
{ "id": "step-2-timer-2", "durationSeconds": 600, "label": "Roast halloumi" } ],
"ingredients": [{ "ingredientId": "halloumi" }] }, …
],
"tips": ["Pat the chickpeas dry before roasting so they crisp rather than steam.", …],
"nutrition": { "basis": "per_serving", "servings": 4, "calories": 470, "proteinG": 24,
"carbsG": 36, "fatG": 25, "source": "calculated" },
"provenance": { "origin": "website", "writtenBy": "chef", "generator": "cloud",
"url": "https://www.example.com/recipes/halloumi-chickpea-traybake",
"siteName": "example.com", "author": "Example Kitchen",
"transformations": [
{ "kind": "servings_rescale", "at": "2026-10-01T18:00:00Z" },
{ "kind": "chef_edit", "engine": "cloud", "at": "2026-10-05T19:12:00Z" } ] }
}
2a. Versions, and recipes from a newer Chef
A recipe's version is two numbers: schemaVersion, the major, and
schemaMinor, the minor (absent = 0). 5.0 is written "schemaVersion": 5
with no schemaMinor.
- Minor changes only add something optional that a reader can leave out without misreading the rest: a field, an enum value, a looser bound. A major change is anything else, and needs a migration step (§7).
- Writers write the lowest minor their content needs (
requiredMinor). A recipe that uses nothing added in 5.1 is written as 5.0, so every 5.x reader can still edit it. - Newer is judged by content as well as numbers. Chef stores only JSON it has validated, so stored JSON of this build's own version that holds a member or a value this build doesn't know can only come from a newer writer — one that forgot its minor, say. It is treated exactly as a newer minor.
- A reader meeting a newer minor of its own major reduces the recipe to what it knows and shows that:
| Unknown | Read as |
|---|---|
| Member of a closed object | Left out (an open object, such as attestation, keeps everything) |
| Unit | The ingredient's custom unit, as written |
Course, difficulty, writtenBy, generator, engine, nutrition source |
Absent |
| Diet | Dropped |
| Origin | imported |
| Transformation kind | cook_edit, so the credit reads "Adapted" |
A changed fixed value (nutrition basis) |
Absent: never read with the old meaning |
An object left without a member it must have is left out whole: optional nutrition, a step. The recipe itself, left so, is not readable.
The recipe is read-only on that build: shown, cooked, shopped and sent
exactly as it is, never edited, refined or rewritten. Chef says "Update Chef
to edit this recipe". It is sent, exported and backed up exactly as
stored, never as the part this build could read. A received file of a
newer minor is kept with its JSON untouched, but only if what this build
reads of it is a valid recipe, and only up to 256 KB.
- A newer major is refused as a file ("Chef can't open this recipe yet").
A recipe synced into the library from a newer Chef is shown from its stored
rows only (its JSON is never read through this build's model, since its
meanings changed) and is also read-only.
- One write gate. SavedRecipe.store refuses to replace a recipe whose
stored JSON is newer than this build reads. That covers the editor, Refine,
Undo and every background pass.
3. Field reference and bounds
Req = required. A text is non-empty, with no leading or trailing
whitespace. A local id matches ^[a-z0-9][a-z0-9._-]{0,63}$ and is unique
across every id in the recipe (ingredients, groups, steps and timers alike).
Bounds are data-sanity limits, not culinary preferences.
Recipe
| Field | Req | Type / bounds | Definition |
|---|---|---|---|
schemaVersion |
✓ | 5 |
The major version (§2a). |
schemaMinor |
int ≥0 | The minor version; absent = 0. Written only when the content needs it. | |
language |
BCP 47 tag ≤35 (en-GB) |
The language the words are in, set only where a route knows it (a page's declared language). Absent = unknown. | |
id |
✓ | lowercase UUID | The recipe's identity. Minted once by its first producer; kept through edits, export and import. |
title |
✓ | text ≤200 | Dish name. |
recipeDescription |
text ≤2000 | The finished dish in a sentence or two. | |
servings |
✓ | int 1–1000 | Portions the stated quantities make. 0 is invalid. |
yieldText |
text ≤80 | The source's own yield wording ("Makes about 20"). | |
totalTimeMinutes |
int 1–525600 | Elapsed minutes from starting to serving. The one answer that filters and choosing read. | |
prepTimeMinutes / cookTimeMinutes |
int 0–525600 | Breakdown. 0 is meaningful ("no cooking"). |
|
difficulty |
easy medium hard |
Effort for an ordinary home cook. | |
cuisine |
text ≤60 | Free text. Not constrained. | |
course |
breakfast starter main side dessert snack baking drink condiment |
The meal role (D2). Text that isn't a course ("Soup") becomes a tag. | |
diets |
set of vegetarian vegan pescatarian |
Declared suitability. Checked against the ingredients and never trusted on its own. | |
tags |
≤30 × tag (≤40, single line, no commas) | Free search words, unique ignoring case. | |
equipment |
≤50 × {name, appliance?} |
Notable kit. appliance uses CookingAppliance raw values when the item is one. |
|
ingredientGroups |
≤30 × {id, title ≤120} |
Ordered ingredient sections. | |
ingredients |
✓ | 1–300 | Ordered: ungrouped items first, then each group's items together, in group order. |
methodGroups |
≤30 × {id, title ≤120} |
Ordered method sections, independent of the ingredient groups. | |
method |
✓ | 1–200 | Ordered; step numbers are positions. Same grouping rule as ingredients. |
tips |
≤20 × text ≤2000 | Advice for the whole recipe, from the author or Chef. | |
nutrition |
object | Per-serving figures. Absent = "Nutrition not added". | |
provenance |
✓ | object | Where the recipe came from and what has happened to it (§3, Provenance). |
attestation |
open object, by and signature required |
Chef's signature vouching for the credit and the content (§10a). Opaque: kept exactly as it arrived, whatever a later Chef puts in it. Checked, never validated beyond its core: a recipe is valid with or without one, and one that doesn't verify, or whose form a reader doesn't know, is treated as absent but kept. |
Ingredient
| Field | Req | Type / bounds | Definition |
|---|---|---|---|
id |
✓ | local id | Stable through renames and reorders. |
name |
✓ | text ≤200 | The ingredient alone. |
quantity |
text ≤80 | The amount as written ("2 × 400g tins", "a pinch"). Required whenever amount is present. |
|
amount |
number >0, ≤1,000,000 | Numeric part, for scaling and shopping. | |
unit |
canonical unit | One of the 39 codes IngredientQuantityParser recognises (g kg ml l tsp tbsp cup oz lb clove tin can pack slice piece bunch handful pinch sprig bag jar punnet pouch rasher stick knob head bulb fillet block sheet stalk strip wedge dash splash squeeze drizzle glug). Only with amount. A test keeps the list equal to the parser's. |
|
customUnit |
text ≤20 | Any other unit ("cm", "fl oz"), kept as written. Never converted or combined. Only with amount, and never alongside unit. |
|
preparation |
text ≤300 | "finely chopped", "skin on". | |
groupId |
local id | Its ingredientGroups entry. |
|
optional |
true |
Present only when the ingredient may be left out. | |
substitutions |
≤10 × text ≤300 | Alternatives in words. Never used in arithmetic or allergen checks. |
Step, timer, ingredient use
| Field | Req | Type / bounds | Definition |
|---|---|---|---|
step id |
✓ | local id | Stable identity for Cook Mode, timers and edits. |
step text |
✓ | text ≤4000 | The instruction. |
step groupId |
local id | Its methodGroups entry. |
|
step timers |
1–20 timers | Zero or more, in the order they run. | |
step ingredients |
1–100 uses | Ingredients the step uses, each at most once. | |
timer id |
✓ | local id | Stable identity for AlarmKit and the Live Activity. |
timer durationSeconds |
✓ | int 1–604800 | Positive, up to 7 days (an overnight or two-day marinade is legitimate). |
timer label |
text ≤60 | What is being timed ("Simmer ragù"). Optional: timers a cook sets by hand and older recipes have none, and the Lock Screen then reads the prose. | |
use ingredientId |
✓ | local id | Must name an ingredient in this recipe. |
use amount |
number >0 | The step's portion. Absent = the amount is in the words of the step (all of it, the remaining, to taste). | |
use unit / customUnit |
as for ingredients | Only with amount, and never both. |
Nutrition
| Field | Req | Type / bounds | Definition |
|---|---|---|---|
basis |
✓ | per_serving |
Every figure is per serving. Whole-recipe figures are divided at capture. |
servings |
✓ | int 1–1000 | The serving count the figures were worked out for. Must equal the recipe's servings. |
calories |
int 0–20000 | kcal per serving. | |
proteinG carbsG fatG |
int 0–2000 | Grams per serving. | |
source |
publisher calculated estimated cook |
Website or a photographed page, as printed / nutrition.ts's ingredient calculation / a model / typed by the cook. Absent = unknown (legacy). |
At least one figure is required. A publisher may give only calories, and a legacy recipe only protein.
Provenance — portable authorship and transformation only
| Field | Req | Type | Definition |
|---|---|---|---|
origin |
✓ | manual website photo prompt imported |
How the recipe entered Chef. imported = route unknown (legacy, or another app's file). |
writtenBy |
cook source chef |
Who wrote the words. Absent = unknown (legacy). | |
generator |
cloud on_device |
The engine that wrote it. Only with writtenBy: chef. |
|
url |
http(s) URL with a host | Source page. Required for origin: website. |
|
siteName |
text ≤120 | The publication the recipe came from. | |
author |
text ≤120 | The person who wrote it: the author a page names, or the cook who signed a recipe they typed (SCOPE §2.5). | |
authorLinks |
1–3 unique https URLs with a host, each ≤500 |
Where to find the author: a profile or website. Only with author. Stored as addresses, never as a platform name, so a new platform needs no contract change. |
|
basedOn |
lowercase UUID | The original this recipe was copied from — the first in the chain, never the copy in between (source.basedOn ?? source.id) — set whenever Chef gives a copy an id of its own (Keep Both, a duplicate, a restored copy, save as new). |
|
transformations |
≤100 × {kind, engine?, at} |
What has happened since, in date order: chef_edit (Refine, change ingredients, change method; engine says which), cook_edit (the cook changed a recipe they didn't write — by hand in Chef's editor, or by refining it with Chef, alongside chef_edit; no engine; at most one a day, never dated before the last change) and servings_rescale (quantities rescaled; arithmetic, so no engine). |
Dates use one convention: ISO 8601, UTC, whole seconds —
2026-10-06T18:30:00Z. The validator also rejects a well-formed but
impossible date (30 February).
Allowed origin × writtenBy: manual→cook, prompt→chef,
website/photo→source or chef, imported→any.
| Route | Provenance written |
|---|---|
| Typed in the editor | manual, cook |
| Create with Chef / Dinner / Ask | prompt, chef, cloud or on_device |
| Website — Keep as written / read by Chef | website, source, url, siteName, author (when the page names one) |
| Website — Make it Chef-ready | website, chef, cloud, url, siteName, author — the source's, with only writtenBy/generator changed |
| Photo of a written recipe | photo, source |
| Photo of a prepared dish | photo, chef, cloud |
| Refine on the review screen, or a Chef edit of a saved recipe | + chef_edit transformation |
| Servings rescaled before saving / on receipt | + servings_rescale transformation |
| A hand edit saved to a recipe the cook didn't write | + cook_edit transformation |
| The cook signs a recipe they typed | author, and authorLinks when given, saved with the recipe |
Received .recipe / pasted v4 or v5 JSON |
unchanged |
Never in provenance: account ids, plan or billing state, prompts, request ids,
audit data, backend-only metadata, or anything else about the person. The only
personal details a recipe may carry are author and authorLinks: a name and
links its writer chose to sign it with, never an account name, Apple ID or
email address.
4. Rule classification
| Kind | Meaning | Rules |
|---|---|---|
| Hard validation | Invalid data. Rejected, never persisted. | Everything in the schema file (types, required fields, enums, bounds, patterns, unknown fields) and the cross-field errors in §6. |
| Warning / quality | Unusual but legitimate. Accepted and flagged; never blocks. | timing.disagrees, timing.part_exceeds_total, nutrition.atwater_mismatch, use.exceeds_quantity, timer.not_in_text, timers.multiple_in_chef_step. |
| Chef generation guidance | What Chef normally produces. Never restricts a valid recipe. Enforced in prompts and the backend's own quality gate, and linted by warnings where useful. | One timer per step; a timer only where the words state a duration; a 2–3 word action-first timer label; description of one or two sentences; one to three tips; nutrition calculated rather than estimated; no ranges in timers; quantities in the UK convention (raw weights for pasta and rice). |
Two timing conflicts are warnings rather than errors on purpose. Prep and cook can overlap, and recipes saved before v4 hold breakdowns that cooks typed by hand. Treating either as invalid would reject legitimate recipes.
5. Kept outside the recipe
| Data | Lives in |
|---|---|
| favourite, rating, would-cook-again, private notes | SavedRecipe |
| times cooked, last cooked, minutes cooking | SavedRecipe |
| created / updated dates | SavedRecipe |
photo (imageData, imageAssetName, photoURLString) |
SavedRecipe |
preferences a recipe was generated under (userPreferencesJSON) |
SavedRecipe — it holds allergies and never travels |
| variant family, library placement | RecipeVariantRecord, RecipeLibraryPlacementRecord |
| shopping selections, ticks, merged quantities | ShoppingListStore (UserDefaults) |
| unfinished draft / Chef result | PendingWorkStore (local file) |
| allergens, free-from status | computed by AllergenScanner, never stored |
response metadata (photoKind, remaining, nutritionComparison) and backend proof data (planStageId) |
the response envelope; a leaked internal field is a schema.additionalProperties error |
6. Validation
producer output ─► repair (P1–P10) ─► validateRecipe ─► errors? ─► reject, persist nothing
legacy input ─► migrate v1→v2→v3→v4 ─► repair ─► validate ─► …
RecipeValidator.validate returns isValid, actionable errors (code, JSON
path, message) and warnings. It is pure. Structure comes from the bundled
schema file, interpreted at run time rather than restated. Cross-field rules
are in Swift. Domain checks call the existing helpers: diets use
DietaryChoice.forbiddenTerms with the shared meat-and-fish list plus
AllergenScanner for vegan dairy and eggs (the rule Refine already applies),
and timers use MethodTimerMatcher. Nothing culinary is restated in JSON
Schema.
The meat-and-fish list is the same words in both languages
(MEAT_AND_FISH in canonicalRecipe.ts, RecipeFineTuneService.meatAndFish
in Swift). On 11 October 2026 it gained guanciale, lardon, veal and venison,
and both validators read a meat word in context (namesMeat): plurals
count, "ies" included (anchovies); a word the name marks as plant food is
not meat ("vegetarian sausages", "meat-free bacon", "Quorn chicken-style
pieces", "mock duck", "fish-free fingers"), nor is a compound that never was
("beef tomatoes", "duck eggs", "lamb's lettuce", "crab apples"); and "mince"
is minced meat unless the name says otherwise (plant-based, vegan, Quorn,
soya, lentil or meat-free mince, mincemeat and a mince pie are not;
"minced garlic" is a preparation). These are refinements of how rule R7
is evaluated, not of what it means — a vegetarian label on a venison
recipe was always a contradiction the validator failed to see — and they
change no field, enum, bound or migration, so the version/migration
process of §16 does not apply (there is no representation for a migration
to change). A word added to the list tightens R7 only for a recipe that
declared a diet it does not keep, and every path that takes a recipe in —
load, receive, the editor's save, a server reply — runs the repairs
before validating, so P7 drops that label rather than rejecting the
recipe: no stored, received or server-produced recipe becomes invalid, and
a build on the older list only accepts what this one would repair. The
manifest's diets.contradicted and warning cases pin both validators to
the same answers.
Errors
| Code | Rule |
|---|---|
schema.* |
Structure: required, additionalProperties, type, enum, const, minLength, maxLength, pattern, minimum, maximum, exclusiveMinimum, minItems, maxItems, uniqueItems. |
version.unsupported / version.not_migrated |
Newer than this app / older and not migrated first. |
ids.duplicate |
Two things share an id. |
group.unknown / group.unused / group.not_contiguous |
Undeclared group; empty group; members not laid out ungrouped-first then group by group. |
unit.conflict |
Canonical and custom unit together. |
ingredient.unit_without_amount / ingredient.amount_without_quantity |
Structure that says more than the words, or an amount without words. |
use.unknown_ingredient / use.duplicate / use.unit_without_amount |
A step uses a missing ingredient, uses one twice, or gives a unit without an amount. |
text.structured_artefact |
Tool-call or JSON fragments in any prose field (RecipeImportSanitizer's rule). |
tags.duplicate |
Tags repeat ignoring case. |
diets.contradicted |
A declared diet clashes with an ingredient. |
nutrition.empty / nutrition.servings_mismatch |
No figures; figures for a different serving count. |
provenance.website_without_url / provenance.invalid_url / provenance.inconsistent / provenance.invalid_date / provenance.not_chronological |
Source-specific requirements, valid web address, allowed combinations, real dates, date order. |
provenance.links_without_author |
authorLinks without author. |
version.newer_minor |
A newer minor of this major: read it reduced (§2a), never store it. |
Repairs — the only changes allowed before failure
Deterministic, idempotent, a no-op on a valid recipe, and never inventing culinary content. The tests check all four properties.
| Repair | |
|---|---|
| P1 | Trim text; cut tool-call/JSON fragments out of prose; drop "", [], null and optional: false. |
| P2 | Loose values: difficulty case; course synonyms ("Dinner" → main; "Cocktail", "Mocktail", "Smoothie", "Hot drink" → drink; text that isn't a course → tag); unit spellings ("tablespoons" → tbsp, anything else → customUnit); diet case. |
| P3 | Tags: split on commas, collapse spaces, drop repeats (first wins). |
| P4 | Lowercase the recipe UUID; normalise, fill and de-duplicate local ids (a repeat becomes -2, -3, …), rewriting every ingredientId and groupId that named a changed id so no reference is left dangling. A repeated id's references all resolve to one of its carriers; the other is then an unused ingredient, or an empty group for P6. |
| P5 | A unit without an amount moves into the words; an amount without words gets them from amount + unit. |
| P6 | Drop empty groups. Ingredients: lay members out contiguously (order inside a group kept). Method: the cooking order is never changed, so groups that are not contiguous are dropped instead (22 October 2026). |
| P7 | Drop a declared diet the ingredients contradict. |
| P8 | Missing total = prep + cook when above zero. |
| P9 | Nutrition worked out for another serving count is restated per serving of this one (same total, divided differently). |
| P10 | Transformations into date order. |
7. Migration
RecipeMigrator.migrateToLatest identifies the version (absent = 1) and steps
through it. It changes representation only, it is deterministic, and running
it on v5 returns v5 unchanged.
| Step | Change |
|---|---|
| v4 → 5.0 | Version number only. Everything 5.0 adds is optional; nothing earlier is changed. |
| v1 → v2 | One spelling per field: description, timeMinutes, instructions, item, note, instruction, timerMinutes, comma-string tags, plain-string steps. |
| v2 → v3 | Every ingredient gets a stable id from its content (the long-standing rule, so an old file re-imports with the same ids). |
| v3 → v4 | The canonical shape. Timer → timers[0]; flat nutrition → nutrition {basis, servings, …}; tips string → list; recipe id and provenance from the importing route. |
Documented interpretations (each leaves a note; nothing else changes):
- free-text category becomes a course, or a tag when it names none;
- an unknown difficulty becomes a tag;
- all-zero nutrition means "not added";
- a legacy file with no servings gets 4, the app's long-standing default;
- fields that were never part of a v1–v3 recipe (photoKind, planStageId) are ignored.
Stored recipes (V8 → V9). The SwiftData stage is lightweight and rewrites
nothing. A record without JSON is read by projecting its columns into v4
(table below) and repairing and validating that projection. On launch,
CanonicalRecipeStore.backfill stores the JSON. It updates updatedDate on
no record and touches no user state, and it rebuilds rows only where repair
changed an id or quantity. A record that still fails is left exactly as it is,
stays readable, and is fixed by the cook's next save; it is never deleted.
| V8 column | v4 |
|---|---|
id |
id, lowercased |
category / tagsText |
course (+ a tag if unmapped) / tags |
| times (0 = none) | absent when 0 |
calories…fat (all 0 = none) |
nutrition, with source unknown |
tips |
[tips] |
source + sourceURLString |
user_created→manual/cook; imported→website (with a URL) or imported; generated→prompt/chef/engine |
ingredient referenceID, step UUID, timerSeconds/timerLabel, usages |
ids, timers[0], uses — sorted by order |
Also migrated on read: variant snapshots (v3 JSON plus their separate prep/cook columns), received files and pasted JSON of any version. Unfinished work still opens. The bundled example and two starters are v4 files (§14 covers the third).
8. Stable identity
| Item | Rule |
|---|---|
| Recipe | Minted once by the first producer (the app today, the backend once it emits v4). Edits keep it. A received file keeps it; if the id is already in the library the cook is offered "open existing" or "save a copy", and a copy gets a new id. |
| Ingredient | Kept by the editor through any edit. Chef edits rejoin by name, then position. New ingredients join the section they were added to. |
| Ingredient / method group | Kept with the recipe. Repair drops a group only when it is empty. |
| Step | Kept by the editor. Chef edits match an unchanged step by its words, then by position. A step's SwiftData row UUID is derived from its id, so Cook Mode's running timers find it again after an edit. |
| Timer | Kept by the editor (extra timers carried untouched). Chef edits keep every timer of an unchanged step and match the rest by duration. |
No edit regenerates nested ids.
9. Storage (D1)
SavedRecipe.recipeJSON (schema V9) holds the canonical JSON and is the source
of truth. CanonicalRecipeStore is the only writer of recipe content. Every
save validates first (an invalid recipe throws and nothing changes), then
writes the JSON and derives every recipe column and the ingredient/step rows
from it. Those exist for filters, search, older builds sharing the iCloud
store and the Live Activity. Two derived columns are new in V9:
nutritionRecorded (so zero-calorie nutrition counts as nutrition) and, on
steps, referenceID and timersJSON (every timer, with the first mirrored in
timerSeconds/timerLabel).
Devices on older builds. A device still on a pre-V9 build shares the iCloud
record. It can edit the columns and rows, but it can't touch the JSON. Each
JSON write therefore stores derivedColumnsDigest (schema V10), a digest of everything an
older build can edit. When the columns no longer match, the load path treats
that edit as the newer one. It merges the edit onto the v4 recipe with the
same id carry-over a Chef edit uses, so groups, extra timers, equipment,
custom units, provenance and genuine zeros survive. The launch backfill then
stores the merge. Changes to favourites, ratings, cooking history and the
photo don't touch the digest and are never mistaken for an edit.
CloudKit fields. Every one is optional or defaulted and additive. Nothing
existing is renamed, retyped or removed, so deploying to production is
non-destructive. Record types follow SwiftData's CD_ mirroring:
| Record type | Field | Type | Schema |
|---|---|---|---|
CD_Recipe |
CD_recipeJSON |
String | V9 |
CD_Recipe |
CD_nutritionRecorded |
Int(64) (Bool) | V9 |
CD_Recipe |
CD_derivedColumnsDigest |
String | V10 |
CD_MethodStep |
CD_referenceID |
String | V9 |
CD_MethodStep |
CD_timersJSON |
String | V9 |
CD_RecipeAcquisitionRecord |
CD_id, CD_recipeID, CD_kind, CD_date |
String/String/String/Date (new record type) | V11 |
V11 adds one record type and touches no recipe field: how a recipe came into
this library when that is not who wrote it (received, for a recipe someone
sent). It is user state (§5) — never part of the recipe, never in a shared
file — and keeps a received hand-written recipe from reading "Your recipe".
10. Sharing and export
A shared recipe is a Recipe File v1 (Sunday Lasagne.recipe,
recipe-file/RECIPE-FILE-v1.md): the
envelope {"format": "recipe", "version": 1, "recipe": …} around the stored
canonical JSON, validated — groups, every timer, prep/cook time, nutrition
with basis and source, provenance, equipment, substitutions and yield. The
envelope's version and the recipe's schemaVersion are separate. The open
format adds no recipe fields and no second model; its schema $refs this
contract's.
On the way out (RecipeJSONExporter.portable) provenance keeps where the
recipe came from but loses one cook's traces: the page address and each
author link drop any sign-in, fragment and tracking parameters, and
transformation times are kept to the day. The photo is not part of the recipe:
it travels beside it in the file's optional image member (Recipe File §2.1),
metadata stripped, with its own source credit when it came from a website.
Credit. Readers credit a recipe from its provenance alone, the same way on
every surface (RecipeCredit). "Adapted" means someone other than the writer
changed it (cook_edit); Chef's own part is always said.
| Provenance | Credit |
|---|---|
| A site, unchanged | "From RecipeTin Eats" |
| A site, changed by the cook | "Adapted from RecipeTin Eats" |
| A site, rewritten or changed by Chef | "Adapted with Chef from RecipeTin Eats" |
An author, unchanged |
"Recipe by Nathan", with its authorLinks |
An author, refined with Chef by its writer |
"Recipe by Nathan, refined with Chef" |
An author, changed by someone else |
"Adapted from a recipe by Nathan" ("Adapted with Chef from…" when Chef helped) |
| Written by Chef from a prompt or a dish photo | "Created with Chef" |
A name is shown on one line, without control or direction-changing
characters. A link is shown by its platform and handle only for an exact
platform host, otherwise by its own domain. It must be https with a plain
ASCII host, and opens only on the reader's tap.
10a. Proof — fingerprints and the attestation
A recipe file is text on someone's device; nothing stops a determined person changing it. Chef's guarantee is that a change never goes unnoticed: Chef never lets credit be altered (SCOPE §2.5), and a recipe Chef has vouched for loses that standing the moment its credit is altered.
Fingerprints (RecipeFingerprint). SHA-256 over the recipe's bytes in the
JSON Canonicalization Scheme (RFC 8785: keys sorted by UTF-16 code units, no
whitespace, ECMAScript number form, minimal string escaping), written
sha256- + base64url without padding. Used only for fingerprints; how a recipe
is stored does not change.
- Credit:
{"id", "provenance"}withprovenance.transformationsandprovenance.basedOnleft out — what is added after the writing — so a credit means the same on every copy. - Content: the recipe without
id,schemaVersion,schemaMinor,provenanceandattestation.
Chef signs a recipe in its portable form (§10), so sending it on never
changes what was signed. A copy with an id of its own (Keep Both, a
duplicate) is checked against the original it was copied from (basedOn).
Signed recipes are never changed by Chef itself. Repair never reaches
into attestation. The way out never re-cleans an attested recipe's credit.
Chef adds no step links or timers to it on its own. A signed recipe is never
"the cook's own", wherever it is, so the cook can't sign it or take its
credit off. Only a person's edit or Refine changes it, and then it reads as
adapted. The photo is never signed.
The attestation (optional, top level, open — the shape below is Chef's first form; a reader keeps whatever else it holds):
"attestation": {
"by": "chef",
"key": { "id": "chef-2027-01", "publicKey": "…", "validFrom": "…", "validUntil": "…",
"certificate": "…" }, // the root key's Ed25519 signature over the key
"signedAt": "2027-02-01T00:00:00Z",
"credit": "sha256-…",
"content": "sha256-…",
"signature": "…" // Ed25519, base64url
}
The key's certificate signs chef-key/1, its id, public key, validFrom and
validUntil, one per line. The signature signs chef-attestation/1, the
recipe id, credit, content, the key id and signedAt, one per line. Chef
ships only the root keys' public halves; signing keys never need an app update.
A check (RecipeAttestation.verify) has three answers:
| Answer | When |
|---|---|
| Verified original | Certificate, signature and both fingerprints match, and the key was valid when it signed. |
| Adapted from a verified recipe | All of that, except the content fingerprint: the credit is Chef's, the words have changed since. |
| Unverified | Anything else, including a form this reader doesn't know. Treated as if there were no attestation, and kept. |
Chef keeps an attestation through the cook's edits, never writes or re-signs one on a device, and trusts nothing a file merely claims.
Before anything shows "verified" (with Collections, not in 5.0):
- Verified means the signature and Chef's current catalogue index.
signedAt is the signer's own claim, so a leaked signing key could
backdate; the index lists withdrawn recipes and revoked keys.
- Only a verified original is marked. "Adapted from a verified recipe"
proves the credit, not the words, so it is shown as an ordinary "Adapted
from a recipe by …".
- Root keys: the first build that verifies ships two, a primary and a
cold backup.
In 5.0 nothing signs and nothing shows; the code that checks exists so its form is fixed and tested.
Import (RecipeDocument.read) is bounded to 8 MB and identifies the file by
its contents, not its name: only an envelope is a recipe file, and a bare
recipe is never one. Recipes don't arrive by link (SCOPE §2.5: no recipe links
or codes at launch). Then migrateRecipeToLatest,
so any version opens and a newer one — envelope or recipe — is refused
("Chef can't open this recipe yet"). A .recipe that is some other program's
(calibre, AutoPkg) is told apart from a damaged one. A received recipe saves
through Review with the sender's id unless the library already holds it, when
a copy gets a new id. That it was received is recorded beside it (V11), not in
it, and goes when the recipe is deleted; sending it on sends exactly what
arrived. User state never travels (§5). ChefRecipeFileTests take every valid
fixture saved → file → open → Review → Save → reopen and compare the whole
recipe; RecipeFileFormatTests and the TypeScript reference check the
published examples.
11. Edge Functions — unchanged, compatible
v5 and the backend. The deployed functions speak v4 and are not changed for
v5. The app migrates every v4 reply to v5 (the version number only). The one
request that carries a canonical recipe — the website rewrite sent to
import-recipe — is written as v4: it cannot contain anything v5 added, since
nothing has been saved or signed yet. _shared/canonicalRecipe.ts stays v4,
and the TypeScript reference adds v5 over it, so nothing undeployed reaches
main.
No deployed Edge Function was changed. (import-recipe, Make it Chef-ready's
candidate on codex/website-import-v4, takes and returns v4 directly; see
QA/website-import-2026-10/README.md. It is not deployed.) The deployed functions still return
the v1–v3-shaped wire format (RecipeImport). Every such result becomes v4
through RecipeMigrator before anything is saved. RecipeMigrator.wireProjection
is the one way back, for the edit endpoint. RecipeMigrator.chefEdit merges
an edit onto the saved recipe so nothing the wire format can't carry is lost.
For the next phase:
1. port docs/canonical-recipe/reference/recipe.ts into _shared (calling
allergens.ts/methodDurations.ts, with a parity fixture);
2. emit v4 from the functions;
3. switch the edit endpoint to v4, then retire RecipeImport.
12. Pipelines and enforcement
| Path | Ends in |
|---|---|
| Manual entry, editing a saved recipe | RecipeDraft.makeCanonical → SavedRecipe.make / store (validate) |
| Website — Keep as written (page publishes a recipe) | JSON-LD + the page's section headings → WebRecipeCanonical (v4, validated, on the phone) → Review (RecipeMigrator.reviewed) → RecipeDraft(canonical:) → same |
| Website — Make it Chef-ready (candidate, not deployed) | that v4 recipe → import-recipe (method rewritten, everything else copied, checked, v4-validated, then charged) → phone re-validates → Review → same |
| Create with Chef, website read as text, photo, Dinner, Ask | wire result → RecipeDraft(importedRecipe:provenance:) → same |
| Refine / change ingredients / change method on a saved recipe | RecipeMigrator.chefEdit → RecipeVariantStore.replace → store |
| Restore / undo a version | snapshot → migrateToLatest → store |
| Received file, pasted v4 JSON | migrateToLatest → RecipeDraft(canonical:) → same |
| Starter recipes | bundled JSON → migrateToLatest → enrichment → SavedRecipe.make |
| Launch, and the app coming to the front | CanonicalRecipeStore.backfill (v4 JSON for records saved before V9), then enrichStepsIfNeeded (links for a recipe with none, timers for a recipe with none — RecipeEnrichmentService; each recipe read once per save date, so one arriving through iCloud is read on the next pass) → store, updatedDate kept |
| Server timers (Create's v4, a photo reading, Chef's edit before the merge) | RecipeMigrator.droppingOffsetTimers — a timer read from an offset ("1 minute before al dente") is dropped; saved and received recipes are never touched |
Development gate: a recipe feature is complete only when its output validates and survives save → load → encode and export → import unchanged.
13. Evidence
- Swift: 885/885 tests pass (
-parallel-testing-enabled NO, iPhone 17e, iOS 27), including 30 new ones: - every valid fixture validates with exactly its expected warnings, and repair leaves it unchanged;
- all 53 malformed cases fail with exactly their expected code; repair rescues the 25 repairable ones idempotently and none of the 28 others;
- all 6 warning cases stay valid;
- v1, v2 and v3 files match hand-checked expectations and are idempotent;
- a real V8 SwiftData store, and a V9 one, open under the current plan and backfill;
- a V8 record migrates to its expected recipe with its user state kept;
- an edit made by an older build is merged rather than ignored, and user state or photo changes are never mistaken for one;
- every fixture survives save → load → encode and export → import byte-for-byte, and survives opening and saving in the editor;
- Chef edits keep ids, groups and extra timers and record
chef_edit; - replace → undo restores a recipe exactly;
- every writing path ends in a valid v4 recipe;
- real backend output (
bbc-easy-chocolate-cake-chef-ready-response.json) validates; - malformed backend output is rejected;
- multiple timers on a step render as separate links and run independently in Cook Mode;
- zero values survive; nothing invalid is persisted; user state never leaves the record;
- the unit vocabulary equals the parser's.
- TypeScript reference: 99/99 checks on the same manifest, the same verdicts and the same migration outputs.
- On the simulator, with real data saved by the previous build:
- all 7 existing recipes migrated, and each passes both validators;
- a v4
chef://recipelink imports losslessly; - Cook Mode runs two labelled timers on one step side by side;
- editing and saving a recipe leaves its canonical JSON byte-identical.
- The fixtures cover all 19 cases in the acceptance brief
(
manifest.json›cases).
14. Known limits (not contract gaps)
- Display. Recipe Detail shows ingredient and method group titles,
substitutions, equipment and diets; Cook Mode shows the method group on each
step and groups and substitutions in its ingredient sheet
(
Views/RecipeDisplayDetails.swift).yieldTextandoptionalare stored, kept and shared but not yet shown on Review, which every received and AI recipe goes through. - Dinner "Edit details first" (hidden in v1) does not carry an in-review Chef refinement into provenance; every other route does.
starter-sausage-ragu.jsonis read-only on disk (0400), so it was left as v3. It seeds correctly through migration; convert it once the permission is intended to change.AllergenScannertreats "oat cream" as milk, so a vegan oat-cream recipe loses its vegan label (P7). This is an existing false positive in domain logic, tracked separately.- CloudKit: the fields in §9 must be deployed to production before the
next TestFlight build (tracked under Open in
RELEASE-MANIFEST.md).
15. Not in 5.0
Quantity or timer ranges (the words keep them), per-step notes, locale, stored allergens or free-from claims, remote image URLs, more nutrients and edit history beyond transformations (what changed, or who changed it). Each is a later version bump if needed.
16. Changing the contract
5.0 is the launch format. After launch:
- A minor change (5.1, 5.2…) only adds something optional that a 5.0
reader can leave out without misreading the rest: a field, an enum value, a
looser bound. It adds a rule to
requiredMinor(so recipes that don't use it stay at the lower minor), a schema file, the validator's rules, fixtures and this document. Older 5.x apps open the new recipes read-only (§2a). - A major change (6.0) is anything else: a removed or renamed field, a meaning that changes, a tighter bound. It needs a migration step, and the previous major must still open in the current app. Only make one when a minor can't do the job.
- Always: update the schema file and
RecipeValidator(and the TypeScript reference) → the fixtures and tests → this document and SCOPE. The frozen launch set (MyRecipesTests/Fixtures/LaunchFormat/) must still open unchanged, and the "from the future" set (FromTheFuture/) must still be read safely. No feature defines its own recipe structure or bypasses validation. The fingerprint form (§10a) never changes; a new form needs a new attestation version.