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

  1. Absent means unknown; zero is a value. An optional field is present and meaningful, or omitted. "", [] and null are never valid. 0 minutes of cooking and 0 calories are real values and round-trip as 0.
  2. The words are the truth; structure supports them. quantity is what the cook reads, and amount/unit serve scaling and shopping. Step text is the instruction, and timers serve Cook Mode. Free-text quantities ("a pinch", "to taste") are never forced into numbers.
  3. 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.
  4. Recipe content and user state are separate (§5).
  5. One spelling per field. Older aliases are accepted only by the migrator.
  6. 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).
  7. 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"} with provenance.transformations and provenance.basedOn left out — what is added after the writing — so a credit means the same on every copy.
  • Content: the recipe without id, schemaVersion, schemaMinor, provenance and attestation.

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://recipe link 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). yieldText and optional are 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.json is 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.
  • AllergenScanner treats "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.