# CoffeeJSON — the complete specification > CoffeeJSON is an open, locale-neutral JSON format for sharing coffee recipes and bean identity. A recipe stops being a screenshot and becomes data any app can read, validate, and brew from. The spec prose and the JSON Schema are CC0 — public domain, no attribution required, no conditions. Generated from the repository. Canonical home: https://coffeejson.org --- # CoffeeJSON v1.0 — Overview CoffeeJSON is a JSON document format for a coffee **brew** and the **coffee** it was made from. A document travels between applications, languages, and locales without loss. This is version **1.0** of the specification, early and still open to change in place ([Versioning § What you can rely on today](https://coffeejson.org/docs/spec/07-versioning.md#what-you-can-rely-on-today)). The format is small on purpose. Reserved areas for future growth are named in [Versioning § Reserved extensions](https://coffeejson.org/docs/spec/07-versioning.md#reserved-extensions). ## Why CoffeeJSON exists CoffeeJSON rests on a simple belief: **coffee gets better for everyone when brewing knowledge can travel.** A good recipe — a roaster's, a champion's, a friend's — stops being useful the moment it is trapped in a screenshot, a blog post, or one app's private database. When the same recipe moves cleanly between apps, languages, and even the bag of beans it was written for, more people brew well and more people enjoy what they drink; drinkers, roasters, cafés, app makers, and gear makers build on each other's work instead of re-typing it. The format is the small, shared, boring thing that makes this possible — and growing it is meant as a contribution *to* the coffee industry, not a land grab *inside* it. ## Scope CoffeeJSON defines two co-equal top-level entities, plus a third that evaluates them. Each is carried as an array collection: - **`recipes`** — the parameters of a brew: dose, water or beverage yield (by `basis`), ratio, temperature, grind, and an ordered sequence of steps. Each element is defined in [Recipe](https://coffeejson.org/docs/spec/03-recipe.md). - **`beans`** — the identity of the coffee: origin, process, varietal, altitude, roast, and the roaster's tasting notes. Each element is defined in [Bean](https://coffeejson.org/docs/spec/04-bean.md). - **`tastings`** — how a brewed cup turned out: the drinker's attributed impression and, kept separately, what an instrument read. Each element is defined in [Tasting](https://coffeejson.org/docs/spec/05-tasting.md). A document carries a `beans` array, a `recipes` array, or both. At least one of the two is present and non-empty. `tastings` does not satisfy that rule, because a tasting evaluates something the document must also carry. There is no singular key. A single coffee or recipe is an array of one. The [envelope](https://coffeejson.org/docs/spec/02-envelope.md) wraps the collections with a version marker. When a `beans` array holds one coffee beside one or more recipes, those recipes are *for* that coffee by co-location. This is the bag-to-brew case, and it needs no identifier. A multi-coffee catalog pairs each recipe with its coffee explicitly: the bean carries an `id` and the recipe a `bean_ref` (see [Envelope § Association](https://coffeejson.org/docs/spec/02-envelope.md#association-explicit-reference)). CoffeeJSON does **not** define a transport. A document is JSON, and you can save it to a file, embed it in a URL, or encode it in a QR code. Recommended bindings are described in the supporting document [Transport](https://coffeejson.org/docs/transport.md). They are not part of the core data model. ## What CoffeeJSON is not - **Not an inventory or journal format.** It carries a coffee's *identity*, a brew's *parameters* and a cup's *outcome*. It never carries personal or stateful data: no bag weight remaining, no "currently brewing" flag, no bag photo (see principle 4). A [Tasting](https://coffeejson.org/docs/spec/05-tasting.md) rates one brew on one occasion and carries no timestamp and no drinker. A coffee itself is never rated. - **Not a sensory-evaluation format.** A roaster's tasting notes are carried as *attributed claims* and a drinker's as *attributed opinion*. A structured cupping score is a reserved extension, not part of v1.0 (see [Versioning § Reserved extensions](https://coffeejson.org/docs/spec/07-versioning.md#reserved-extensions)). - **Not a grinder-conversion engine.** Grind settings are captured as the sender expressed them. The format never converts one grinder's scale to another's (see [Recipe § Grind](https://coffeejson.org/docs/spec/03-recipe.md)). ## Design principles Two kinds of principle run through CoffeeJSON: how the **data** is modeled, and how the **format** is adopted and grown. The first four drive nearly every field-level decision. The last five drive what gets added, what gets refused, and how easy the format is to adopt. ### How the data is modeled 1. **Fact, claim, and opinion are distinct.** Every piece of information is one of three tiers, and each tier is encoded differently: | Tier | What it is | Where it lives | Encoding | | --- | --- | --- | --- | | **Measured fact** | reproducible with a scale, a thermometer, and a timer | Recipe quantities, times, per-step `to_water`; a Tasting's `measured` | values with canonical [units](https://coffeejson.org/docs/spec/06-vocabularies.md#units) | | **Declared claim** | what the roaster states | Bean identity fields; the recipe's `recommended` | normalized to queryable forms (ISO country, meters, Agtron, ISO date) — claimed, never verified | | **Attributed opinion** | a sensory impression, a rating | `roaster_notes` and `description`; a Tasting's `rating`, `perceived`, `descriptors` | always attributed to its source; never asserted as bare fact | A descriptor is never promoted to a claim, and a consumer **MUST NOT** merge one source's descriptors with another's. A rating attaches to a cup ([Tasting](https://coffeejson.org/docs/spec/05-tasting.md)), never to a coffee. A Tasting carries fact (`measured`) beside opinion (`perceived`) in separate members, so neither is rendered as the other. A field that is none of the three is personal state and does not belong in the format (principle 4). 2. **Locale-neutral on the wire, localized at the edges.** Everything enumerable travels as a stable, language-independent machine id: methods, processes, roast levels, countries, gear, varietals. Each consuming application renders its own localized display string from that id. Human free text, such as a title or a custom step note, travels as written with an optional `lang` hint. 3. **Canonical unit identifiers.** Measurement units travel as semantic, locale-neutral identifiers such as `gram`, `ounce`, `celsius`, `fahrenheit`, `meter`, and `foot`. They never travel as display symbols such as `g`, `oz`, `°C`, or `°F`. Producers MUST emit these canonical identifiers, and consumers MUST localize display at the edges. 4. **Identity, not inventory.** The format carries a coffee's identity (origin / process / roast) and a brew's parameters. Three kinds of thing stay out, and roasters publish all three: - **Personal and inventory state** — a bag's weight, a purchase date, a drinker's own rating. If a field only makes sense for *one owner at one moment*, it does not belong here. - **Commercial state** — price, green cost, lot size, stock status, cups per bag, a supplier-pay grade. These are facts about a transaction, not about the coffee, and they go stale the moment the bag is sold. - **Third-party judgments** — a cup score, a competition placement, a traceability grade. They are someone's evaluation rather than the coffee's identity. A scored cup evaluation is a separate entity, reserved by name in [Versioning § Reserved extensions](https://coffeejson.org/docs/spec/07-versioning.md#reserved-extensions). The test that decides all three: **would this still be true, and still about this coffee, a year from now in someone else's hands?** ### How the format is adopted and grown 5. **Portable and drop-in.** A document is *just JSON*: no backend, no account, nothing that has to phone home. It saves to a file, rides inside a share URL, or prints as a QR code. A typical recipe is a few hundred bytes, small enough to travel inside the link itself, compressed or not ([transport](https://coffeejson.org/docs/transport.md)). CoffeeJSON defines *no* transport of its own, so it drops into whatever channel an application already has. 6. **Incremental to adopt.** Implement a little or a lot. The smallest valid document is a title and two measurements. Everything richer is optional: method, grind, steps, a `beans` collection beside the recipes. A useful integration is an afternoon's work, and it can deepen later without a rewrite of what came first. 7. **Extensible, never breaking.** The format grows *additively*. New optional fields and reserved areas ([Versioning § Reserved extensions](https://coffeejson.org/docs/spec/07-versioning.md#reserved-extensions)) arrive within a major version. Consumers **MUST ignore what they do not recognize** rather than fail, so a document that is valid today stays valid as the format grows. The precise consumer obligation is the [forward-compatibility contract](#the-forward-compatibility-contract-summary). (While there is one implementation the format may still evolve in place — [Versioning § Evolving 1.0 in place](https://coffeejson.org/docs/spec/07-versioning.md#evolving-10-in-place). From first outside adoption this principle binds unconditionally.) 8. **No lock-in.** CoffeeJSON is [CC0](https://creativecommons.org/publicdomain/zero/1.0/) public domain: the spec, the schema, the fixtures, the registries, and the corpus's structure and transcription. Quoted roaster prose in corpus documents remains the quoted source's, attributed. The registries are open, and the ids are vendor-neutral, so nothing in a document encodes one implementation's private scheme. What one implementation writes, any other can read. What a user shared, they can always get back. The format traps no one. 9. **Minimal — resist overengineering and overgeneralization.** Model what is real, common, and verifiable. *Reserve the rest by name* (principle 7) instead of half-building it. Refuse abstractions that would emit confidently-wrong data. The format captures a grind setting as stated. It will not pretend to convert one grinder's scale to another's. A small format that ships and interoperates beats a general one that does neither. When in doubt, leave it out. An optional field can always be added later, but it can never be cleanly removed. ## Conformance language The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this specification are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). They carry their special meaning only when in **ALL CAPITALS**. Two roles are referenced throughout: - A **producer** is software that emits CoffeeJSON documents. - A **consumer** is software that reads them. A single application is usually both. Requirements are stated against the role they constrain. Where a requirement is unqualified, it applies to both. ### The forward-compatibility contract (summary) The rule from principle 7 (*extensible, never breaking*) governs how every consumer behaves, so it is restated here as a contract: - A consumer **MUST** ignore any object member it does not recognize, at any depth, and **MUST** continue processing the members it does recognize. An *element* of a collection that is not an object is not an unknown member ([Envelope § Fields](https://coffeejson.org/docs/spec/02-envelope.md#fields)). - A consumer **MUST NOT** reject a document solely because it contains unknown members or unknown enumerated values. Unknown enumerated values are handled per each vocabulary's rule, usually by mapping to a defined `other` or fallback value (see [Vocabularies](https://coffeejson.org/docs/spec/06-vocabularies.md)). - A consumer **MAY** reject a document whose **major** `coffeejson` version it does not support (see [Versioning](https://coffeejson.org/docs/spec/07-versioning.md)). ### Preservation on re-share The contract above governs *reading*. On the way back out, when an application emits a document that carries data it did not author, two cases differ. The rule is to be honest about which one is happening: - **Round-trip:** the consumer re-emits a document it imported, with the carried data unedited (import → share on). The consumer **SHOULD preserve** members it did not recognize rather than strip them, so a document does not silently shed data as it travels through less-capable consumers. - **Re-authoring:** the producer rebuilds a document from its own model, such as an edit form or an internal library. The producer **MAY drop** what it does not model, and **SHOULD disclose** that the re-emitted document is its own reduction of the original (for example, "re-exported — some original data not carried"). --- # Document envelope A CoffeeJSON document is a JSON object with a single version marker plus three OPTIONAL array collections: `beans`, `recipes` and `tastings`. **At least one** of `beans` or `recipes` must be present and non-empty. ```json { "coffeejson": "1.0", "recipes": [ /* one or more Recipe */ ] } ``` ```json { "coffeejson": "1.0", "beans": [ /* one or more Bean */ ] } ``` ## Fields | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `coffeejson` | string | yes | The **schema version** of the format itself (semver). See below. | | `beans` | array of Bean | cond.¹ | Zero or more [Bean](https://coffeejson.org/docs/spec/04-bean.md) objects. One element shares a single coffee; several form a catalog / lineup. Omit when empty. | | `recipes` | array of Recipe | cond.¹ | Zero or more [Recipe](https://coffeejson.org/docs/spec/03-recipe.md) objects. One element is a share; several are a library export. Omit when empty. | | `tastings` | array of Tasting | no | Zero or more [Tasting](https://coffeejson.org/docs/spec/05-tasting.md#fields) objects — how a brewed cup actually turned out. Each rides with the recipe it evaluates. Omit when empty. | | `generator` | object | no | The software that wrote this document: `{ "name": string, "version": string, "url": string (URI) }`, `name` required. Informational; consumers **MUST NOT** depend on it. See below. | ¹ A document **MUST** contain **at least one** of `beans` or `recipes`, present and **non-empty**. A document with neither, or with only empty arrays, is invalid, and a consumer **SHOULD** reject it. There is no singular `bean` or `recipe` key. A single coffee or recipe is an array of one element. `tastings` does **not** satisfy that rule. A tasting evaluates something, so a document that carries only tastings describes nothing a consumer can act on. It is invalid for the same reason an empty document is. Every element of `beans`, `recipes` and `tastings` **MUST** be a JSON object. A collection holding anything else — `null`, a number, a string, a nested array — is malformed. Such an element is **not** an unknown member the [forward-compatibility contract](https://coffeejson.org/docs/spec/01-overview.md#the-forward-compatibility-contract-summary) tells a consumer to ignore; it is a slot that claims to hold an entity and does not. What a consumer does next depends on its role. One that imports into a store **SHOULD** reject the document and name the collection. One that only renders **MAY** skip the element and render the rest. Both are conformant; a consumer picks by what it is, not by reading this rule twice. ### Why always-array, not singular + plural CoffeeJSON exposes each collection as **only** an array. There is no singular key beside a plural one, and no field that is sometimes an object and sometimes an array. A shape that varies by cardinality forces every consumer to branch (*is it one or many?*) before it can use the data, the known rough edge of HAL's `_embedded`. With one fixed shape, a strongly-typed consumer decodes the document as a plain three-collection structure (`beans: [Bean]`, `recipes: [Recipe]`, `tastings: [Tasting]`) with no discriminator and no polymorphism. "The single one" is the first element. This also follows [BeerJSON](https://github.com/beerjson/beerjson), whose collections are only arrays and whose only scalar is the version marker. ### `coffeejson` `coffeejson` names the version of the *specification* a document conforms to. This follows the convention, shared with OpenAPI and BeerJSON, where the root marker names the spec and its version. It is **not** a per-recipe revision number, and it is not a property of any individual recipe. The value is a [semantic version](https://coffeejson.org/docs/spec/07-versioning.md). For v1.0 the value is the string `"1.0"`. A consumer determines support by the **major** component: - A consumer **MAY** reject a document whose major version it does not implement. It **SHOULD** show a clear "unsupported version" message rather than fail opaquely. - A consumer **SHOULD** accept a document with the same major version and a newer minor version. The [forward-compatibility contract](https://coffeejson.org/docs/spec/01-overview.md) tells it to ignore what it does not recognize. ### `generator` `generator` records what **software** emitted this JSON: its name, its version, and optionally its own URL. `name` is required, because a generator that does not name the software states nothing. `version` and `url` are optional. `url` must be a well-formed URI. The emitter need not be an application. A hosted service, a build script, a command-line tool, or a language model can write a CoffeeJSON document. Each of them names itself in the same member. ```json { "coffeejson": "1.0", "generator": { "name": "ExampleBrewApp", "version": "2.3.0", "url": "https://example.com/brewapp" }, "recipes": [ { "title": "Weekday V60", "coffee": { "value": 15, "unit": "gram" }, "water": { "value": 250, "unit": "gram" } } ] } ``` It is **informational only**. A consumer **MUST NOT** change how it imports a document based on `generator`. It **MAY** show it, for example as an "Imported from …" marker. Nothing in the format depends on it. A document that omits it is in no way lesser, and most are written by hand. **Why it sits on the envelope.** A file is written once, by one program. Which software serialized it is a fact about the *document*, not about any recipe or coffee inside it. A three-recipe export would otherwise repeat an identical value three times. A document that carries only `beans` would have nowhere to put it. This follows Atom's ``, which identifies the agent that produced a feed and sits on the feed rather than the entry. It also follows schema.org, which attaches document provenance to the top-level `CreativeWork`. **Distinct from the two attribution fields.** Both stay on the Recipe because they vary between recipes in one document. [`based_on`](https://coffeejson.org/docs/spec/03-recipe.md#attribution-author-based_on) is where a recipe was *published*, and [`author`](https://coffeejson.org/docs/spec/03-recipe.md#attribution-author-based_on) is who devised it. `generator` is neither. Software that exports someone else's recipe is not its author and not its publisher. See [Recipe § Attribution](https://coffeejson.org/docs/spec/03-recipe.md#attribution-author-based_on). ## Single vs. multiple recipes The `recipes` array does two jobs. Only the number of elements tells them apart: - **One element** is the unit of *sharing*. A single recipe keeps a [share link](https://coffeejson.org/docs/transport.md) small and unambiguous. - **Several elements** are the unit of *export*. A library or backup contains many recipes. One document wraps them all, so there is no version marker per recipe. A consumer **MUST** parse the `recipes` array whatever its length. It **SHOULD** report how many recipes it found rather than fail silently. ## Single vs. multiple beans `beans` mirrors `recipes` exactly. It is the same array, told apart by length: - **One element** is the unit of *sharing*: a single coffee, on its own or paired with a brew, that you want someone else to taste. - **Several elements** are the unit of *cataloguing*: a roaster's current lineup, or an app's library of distinct coffees, all in one document. A **blend is still one Bean**, one element of `beans`. It is expressed as an [origin](https://coffeejson.org/docs/spec/04-bean.md#origin-object) of `type: "blend"` with several `items`. A multi-element `beans` array means several **distinct coffees**, never the components of one blend. ```json { "coffeejson": "1.0", "beans": [ { "name": "Nano Challa", "roaster": { "name": "Example Roastery" }, "process": ["washed"], "roast_level": "light_medium" } ] } ``` A `beans` catalog is the same Bean object, several times over: ```json { "coffeejson": "1.0", "beans": [ { "name": "Nano Challa", "roaster": { "name": "Example Roastery" }, "process": ["washed"] }, { "name": "Las Brisas", "roaster": { "name": "Example Roastery" }, "process": ["natural"] } ] } ``` ## Association (co-location) When `beans` holds **exactly one** element beside a non-empty `recipes` array, every recipe without a [`bean_ref`](https://coffeejson.org/docs/spec/03-recipe.md#bean_ref) is **for that one coffee**: the bag together with the way to brew it. This is the **bag-to-brew** case. The association is by **co-location alone**. Because there is a single bean, no identifier and no reference are needed (`beans.length == 1` is the trigger). An explicit `bean_ref`, when present, always takes precedence. See [Association (explicit reference)](#association-explicit-reference). ```json { "coffeejson": "1.0", "beans": [ { "name": "Nano Challa", "roaster": { "name": "Example Roastery" }, "process": ["washed"], "roast_level": "light_medium" } ], "recipes": [ { "title": "Roaster's V60", "method": "pour_over", "coffee": { "value": 15, "unit": "gram" }, "water": { "value": 250, "unit": "gram" } } ] } ``` That single bean can carry **several** recommended recipes. They are all for that one coffee: ```json { "coffeejson": "1.0", "beans": [ { "name": "Nano Challa", "roaster": { "name": "Example Roastery" } } ], "recipes": [ { "title": "V60", "method": "pour_over", "coffee": { "value": 15, "unit": "gram" }, "water": { "value": 250, "unit": "gram" } }, { "title": "AeroPress", "method": "aeropress", "coffee": { "value": 14, "unit": "gram" }, "water": { "value": 220, "unit": "gram" } } ] } ``` ## Association (explicit reference) Co-location resolves the association only when `beans` has **one** element. A `beans` array of **two or more** elements together with `recipes` cannot be read from position. Which recipe belongs to which coffee needs an explicit reference: an [`id`](https://coffeejson.org/docs/spec/04-bean.md#id) on each referenced bean, and a [`bean_ref`](https://coffeejson.org/docs/spec/03-recipe.md#bean_ref) on each recipe that belongs to a specific coffee. ```json { "coffeejson": "1.0", "beans": [ { "id": "nano-challa", "name": "Nano Challa", "roaster": { "name": "Example Roastery" }, "process": ["washed"] }, { "id": "las-brisas", "name": "Las Brisas", "roaster": { "name": "Example Roastery" }, "process": ["natural"] } ], "recipes": [ { "title": "Nano Challa V60", "bean_ref": "nano-challa", "recommended": true, "method": "pour_over", "coffee": { "value": 15, "unit": "gram" }, "water": { "value": 250, "unit": "gram" } }, { "title": "Las Brisas French Press", "bean_ref": "las-brisas", "method": "french_press", "coffee": { "value": 30, "unit": "gram" }, "water": { "value": 500, "unit": "gram" } } ] } ``` For each recipe, association resolves by one rule: **an explicit reference wins, and co-location covers the single-bean case**. 1. If the recipe carries `bean_ref`, it is associated with the bean whose `id` equals that value, an **exact, case-sensitive** string match. If no bean matches, the recipe is associated with **no** bean. An unresolved reference is never an error, and there is **no** fall-back to co-location, because an explicit reference wins even when broken. A validator **SHOULD** warn. Because the match is byte-exact, producers **MUST** emit `id` and `bean_ref` in Unicode **NFC** normalization form. The same visible string in two normalization forms (a name with a combining accent, say) would otherwise silently fail to link. Producers **SHOULD** emit all human-text strings in NFC. 2. Otherwise, if `beans` holds exactly one element, the recipe is associated with that bean by [co-location](#association-co-location). 3. Otherwise, the recipe is associated with no bean. Every `id` present **MUST** be unique within `beans` ([Bean § `id`](https://coffeejson.org/docs/spec/04-bean.md#id)). If several beans share an id, the document is malformed. A consumer **MUST NOT** fail and **SHOULD** treat references to that id as unresolved. A multi-element `beans` array whose recipes carry no `bean_ref` remains a **valid** document, and a consumer **MUST NOT** treat it as an error. It imports the beans and recipes as independent entities and draws no links between them, per the [forward-compatibility contract](https://coffeejson.org/docs/spec/01-overview.md). ## Association (a tasting's recipe and coffee) A [Tasting](https://coffeejson.org/docs/spec/05-tasting.md#fields) points at what it evaluates. It resolves its **recipe** and its **coffee** independently. The recipe is resolved by `recipe_ref` alone: the recipe whose [`id`](https://coffeejson.org/docs/spec/03-recipe.md#id) equals that value, an **exact, case-sensitive** match, with the same NFC requirement as every other reference in this format. An unresolved `recipe_ref` leaves the tasting unlinked. A consumer **MUST NOT** fail, and **SHOULD** warn. There is no positional fall-back. A tasting with no `recipe_ref` names no recipe. The coffee resolves by the same two-step rule the recipes use: **an explicit reference wins, and co-location covers the single-bean case**. 1. If the tasting carries `bean_ref`, it is associated with the bean whose `id` equals that value, exactly and case-sensitively. This holds **even when the referenced recipe names a different bean**. See below. 2. Otherwise, if `beans` holds exactly one element, the tasting is associated with that bean by [co-location](#association-co-location). 3. Otherwise, the tasting is associated with no bean. **A tasting's own `bean_ref` wins over its recipe's.** When the two disagree, the document is not malformed, and a consumer **MUST NOT** report a conflict. It is *"I brewed your recipe with my coffee"*, the ordinary case for a recipe someone else published. A consumer renders the tasting against the bean the *tasting* names, and leaves the recipe's own association untouched. ```json { "coffeejson": "1.0", "beans": [ { "id": "nano-challa", "name": "Nano Challa", "roaster": { "name": "Example Roastery" } }, { "id": "las-brisas", "name": "Las Brisas", "roaster": { "name": "Example Roastery" } } ], "recipes": [ { "id": "roasters-v60", "title": "Roaster's V60", "bean_ref": "nano-challa", "method": "pour_over", "coffee": { "value": 15, "unit": "gram" }, "water": { "value": 250, "unit": "gram" } } ], "tastings": [ { "recipe_ref": "roasters-v60", "bean_ref": "las-brisas", "rating": 4 } ] } ``` That cup followed the Nano Challa recipe but was brewed with Las Brisas. The recipe is still *for* Nano Challa. The tasting is *about* Las Brisas. Every `id` present **MUST** be unique within its own collection, and `tastings` is no different from `beans` and `recipes`. Duplicates make the document malformed. A consumer **MUST NOT** fail and **SHOULD** treat references to that id as unresolved. ## Minimal valid document The smallest conformant document is a `recipes` array of one recipe with a title and the two required measurements: ```json { "coffeejson": "1.0", "recipes": [ { "title": "Everyday V60", "coffee": { "value": 15, "unit": "gram" }, "water": { "value": 250, "unit": "gram" } } ] } ``` Everything else in a Recipe is optional. See [Recipe](https://coffeejson.org/docs/spec/03-recipe.md) for the full field set and [`fixtures/valid/`](https://github.com/coffeejson-org/coffeejson/blob/main/fixtures/README.md#valid) for richer documents. --- # Recipe A **Recipe** describes the parameters of a brew. It is the primary entity of CoffeeJSON v1.0. It and the [Bean](https://coffeejson.org/docs/spec/04-bean.md) are the two co-equal entities at the top level of a [document](https://coffeejson.org/docs/spec/02-envelope.md), each carried in its own array (`recipes`, `beans`). A [Tasting](https://coffeejson.org/docs/spec/05-tasting.md) evaluates a brew of one and is carried in a third. ```json { "title": "Sunday V60", "method": "pour_over", "brewer": { "id": "hario-v60", "brand": "Hario", "model": "V60", "label": "Hario V60" }, "coffee": { "value": 15, "unit": "gram" }, "water": { "value": 250, "unit": "gram" }, "ratio": 16.7, "water_temp": { "value": 94, "unit": "celsius" }, "grind": { "grinder": { "id": "comandante-c40", "label": "Comandante C40" }, "setting": "22 clicks" }, "steps": [ /* … */ ], "finish_s": 150 } ``` ## Fields | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `id` | string | no | Document-local name for this recipe, so one of several can be addressed. See [`id`](#id). | | `title` | string | yes | Human text, as written by the author. Never empty. Language hinted by `lang`. | | `description` | string | no | One- or two-sentence summary — the preview/snippet text. Distinct from `notes`. Human text — see `lang`. See [`description`](#description). | | `method` | string (enum) | no | Brewing **technique** — see [`method` vocabulary](#method-vocabulary). Omit if unknown. | | `basis` | string (enum) | no | Which quantity the recipe is stated in: `water` (default when absent — requires `water` or `ratio`) · `yield` (requires `yield`, forbids `water`/`ratio`). The structural switch; `method` stays descriptive. See [Espresso (dose : yield)](#espresso-dose--yield) and [Vocabularies § `basis`](https://coffeejson.org/docs/spec/06-vocabularies.md#basis). | | `brewer` | [Gear](#gear-object) | no | The **device** (V60, Origami, AeroPress…). Omit if unknown. | | `coffee` | [Measurement](#measurement-object) | yes | Coffee **dose** (mass). Recommended unit `gram`. | | `water` | [Measurement](#measurement-object) | cond.¹ | Total brew water. Recommended unit `gram`. Present on a water-basis recipe (`basis:"water"`, or absent — the default); not on a yield-basis recipe. | | `yield` | [Measurement](#measurement-object) | cond.¹ | Beverage mass **out**, in the cup — the beverage a brew produces, meaningful for any method (distinct from input `water`). REQUIRED when `basis` is `yield`; optional otherwise. | | `ratio` | number | cond.¹ | Water-to-coffee ratio (dimensionless, for example `16.7`). See [Ratio](#ratio). | | `water_temp` | [Measurement](#measurement-object) | no | Brew-water temperature. Recommended unit `celsius`. | | `grind` | [Grind](#grind-object) | no | Structured grind specification. | | `pressure` | [Measurement](#measurement-object) | no | Nominal peak brew pressure. Recommended unit `bar`. See [Espresso](#espresso-dose--yield). | | `preinfusion_s` | number | no | Seconds of low-pressure pre-wetting before full pressure. | | `basket` | [Gear](#gear-object) | no | The filter **basket** (espresso) — for example an 18 g precision basket. | | `filter` | [Filter](#filter-object) | no | The brew filter — what the water passes through. See [Filter](#filter-object). | | `steps` | array of [Step](#step-object) | no | Ordered, typed brew steps. Omit or empty for a recipe with no step guide. | | `finish_s` | number | no | Seconds from start to the cue to draw down / remove the brewer — for `espresso`, the target **shot time**. | | `lang` | string (BCP-47) | no | Language of the human text fields (`title`, custom labels), as a well-formed BCP-47 tag — `en`, `en-US`, `zh-Hant-TW`; hyphen-separated, so `en_US` is **invalid**. A hint only. | | `author` | [Party](#party-object) | no | Who authored this recipe — a person or organization. See [Attribution](#attribution-author-based_on). | | `based_on` | string (URI) | no | Where this recipe was originally published, for a transcribed recipe. See [Attribution](#attribution-author-based_on). | | `images` | array of string (URI) | no | Image URLs for the recipe/brew. Always an array; omit or empty when none. See [Images & publication date](#images--publication-date). | | `date_published` | string (ISO 8601 date) | no | When this recipe was first published. See [Images & publication date](#images--publication-date). | | `bean_ref` | string | no | The [`id`](https://coffeejson.org/docs/spec/04-bean.md#id) of the bean in this document that this recipe is for. See [`bean_ref`](#bean_ref). | | `recommended` | boolean | no | `true` marks this recipe as the producer's recommended brew. Omit rather than emit `false`. See [`recommended`](#recommended). | | `notes` | string | no | Free-text prose about the whole recipe (character, tips, troubleshooting). Human text — see `lang`. See [`notes`](#notes). | | `additions` | array of [Addition](#addition-object) | no | Liquids added beyond the brew `water` — ice, milk, sugar, syrup, and more. See [Additions](#additions). | | `localizations` | object | no | The publisher's own translations of this recipe's human text, keyed by BCP-47 tag. Requires `lang`. See [Localizations](#localizations). | ¹ The stated brew quantity switches with `basis`. See [Espresso (dose : yield)](#espresso-dose--yield). A water-basis recipe (`basis:"water"`, or absent, the default) REQUIRES **`water` or `ratio`**, and MAY add `yield`. A `basis:"yield"` recipe REQUIRES `yield` and **MUST NOT** carry `water` or `ratio`. `coffee` is always required, so `water` and `ratio` each fix the other. One of them is enough. Recipes are commonly published as a dose and a ratio ("20 g at 1:15") with no total printed. A required `water` would force a producer to compute and assert a figure the source never gave. A consumer that needs the total when only `ratio` is stated derives it as `coffee × ratio`. A producer that knows both SHOULD state both. `coffee` is the **dose**, the mass of coffee. The coffee's *identity* (where it is from, how it is processed) lives in the top-level [Bean](https://coffeejson.org/docs/spec/04-bean.md) entity, never in the recipe. ### Ratio `ratio` is the water-to-coffee ratio as a bare dimensionless number (water ÷ coffee by mass), for example `16.7` for a 1:16.7 brew. - If `ratio` is omitted, a consumer computes it from `water` / `coffee`. Both operands come to one mass unit — grams — before they divide, so 15 g of coffee to 8 oz of water is 1:15.1 and never 1:0.5. An operand stated by volume, or in a unit the consumer does not recognize, yields **no** ratio, and a window is reduced only under [Stated windows](#stated-windows). - If `ratio` is present but inconsistent with `coffee` and `water`, a consumer **SHOULD** prefer the explicit `coffee` / `water` measurements and **MAY** recompute `ratio`. The measurements are authoritative. The ratio is a convenience. `ratio` describes `water`, so it never appears on a yield-basis recipe. See the next section. Two further rules follow from what `ratio` is: - **It is a mass ratio, so it is absent when `water` is a volume.** A recipe that states 13 g of coffee to 225 mL of water states no mass ratio. The format does not compute one, because that needs the density conversion [Water quantity](#water-quantity) declines to define. A consumer that shows "1:17" there is showing its own arithmetic. - **It is often what couples two windows.** A French press guide can state 25–45 g of coffee to 375–675 g of water, because the recipe scales with the press. When both are [windows](#stated-windows), the single ratio of 15 holds across the range. `ratio` is the only field that says so. Emit it. ### Scaling A consumer that lets the user brew a recipe at a different batch size **SHOULD** scale by multiplying every mass by one factor: `coffee`, `water` (or `yield`), each addition's `amount`, and each step's cumulative `to_water`. `ratio` is a mass quotient, so it stays invariant. Nothing else scales. Timings (`at_s`, `action_duration_s`, `finish_s`), `water_temp`, `grind`, and `pressure` are the author's technique, not functions of batch size, and the format defines no rule for them. A consumer that re-times a scaled brew applies its own brewing model. It **MUST NOT** present the result as the author's schedule. A scaled document is a re-authored one. Emit the scaled masses as ordinary values. There is no scale-factor field. ### Espresso (dose : yield) `basis: "yield"` switches the recipe's stated brew quantity. An espresso recipe sets it, because espresso states what lands **in the cup**, not the water that goes in. The puck retains roughly twice its dry mass, and no espresso recipe states input water. - `coffee` remains the **dose** in, and `yield` is **REQUIRED**: the beverage mass out, for example 19 g in → 47 g out. - `water` and `ratio` **MUST NOT** be present. Their definitions (total brew water, water ÷ coffee) have nothing true to say about a shot, and the [schema](https://coffeejson.org/schema/1.0) rejects them. A yield can never masquerade as water. - A consumer shows the espresso ratio as **dose : beverage**, derived from the measurements. 19 g → 47 g reads as 1 : 2.5 (`yield ÷ coffee`). - `finish_s` is the target **shot time**. `water_temp` is the brew temperature, as for any method. - `pressure` (nominal peak, recommended unit `bar`), `preinfusion_s` (seconds of low-pressure pre-wetting), and `basket` (a [Gear](#gear-object) for the filter basket) complete the shot's parameters. The espresso *machine* is the recipe's `brewer`, as usual. - The espresso step kinds are `distribute` (WDT), `tamp`, and `pull`. See [Step](#step-object). The shot's numbers live at the recipe level. A `pull` step carries instructions, never measurements. ```json { "coffeejson": "1.0", "recipes": [ { "title": "Roaster's espresso", "method": "espresso", "basis": "yield", "coffee": { "value": 19, "unit": "gram" }, "yield": { "value": 47, "unit": "gram" }, "water_temp": { "value": 93, "unit": "celsius" }, "pressure": { "value": 9, "unit": "bar" }, "preinfusion_s": 3.5, "finish_s": 26.5, "steps": [ { "kind": "distribute", "instruction": "WDT, level the bed" }, { "kind": "tamp" }, { "kind": "pull", "instruction": "line pressure to 3.5 s, then 9 bar" } ] } ] } ``` For every **water-basis** recipe (the default) the model is unchanged. `water` is required, and `yield` MAY also state the beverage mass out. `yield` is the same universal quantity, not an espresso-specific field: a filter brew's output is its water minus what the bed retains. A yield-basis recipe is the one that *requires* `yield`, because it alone has no input-water figure to state. Espresso is the motivating case. Multi-phase pressure / flow **profiles**, a named machine-executable curve (Decent-style shot files, Fellow Aiden profiles), are not modeled in v1.0. A producer describes phases in step `instruction` text. Structured profiling is reserved by name in [Versioning § Reserved extensions](https://coffeejson.org/docs/spec/07-versioning.md#reserved-extensions). ### Localizations A document is written in one language, named by [`lang`](#fields). Some publishers write in two. A roaster prints a Japanese bag and its own English beside it. A barista publishes a method in their language and in English. `localizations` carries those, keyed by BCP-47 tag: ```json { "title": "4:6メソッド", "lang": "ja", "coffee": { "value": 20, "unit": "gram" }, "water": { "value": 300, "unit": "gram" }, "steps": [ { "at_s": 0, "to_water": { "value": 60, "unit": "gram" }, "instruction": "1投目 — 甘さを決める" }, { "at_s": 90, "to_water": { "value": 300, "unit": "gram" }, "instruction": "2投目 — 濃度を決める" } ], "localizations": { "en": { "title": "4:6 Method", "steps": [ { "instruction": "first pour — sets sweetness" }, { "instruction": "second pour — sets strength" } ] } } } ``` **Only wording varies.** A localization carries `title`, `description`, `notes`, and per-step `instruction` / `label`, and nothing else. Every quantity, unit, enum, piece of gear, and reference belongs to the recipe itself and is the same in every language. A translation that changed a dose would be a *different recipe* with a language tag. The [authoring schema](https://coffeejson.org/schema/authoring/1.0) rejects any other member. The runtime schema is open like every other object here, so a future minor can add a text field. **`lang` is required when `localizations` is present.** An overlay overrides a base, so the base's language has to be stated. Otherwise nothing says what is translated *from*. The schema enforces this. **Step wording is positional.** Entry *i* of a localization's `steps` translates step *i*. An empty object `{}` leaves that step untranslated, which is how a publisher who translated two of four steps says so. The array **MUST** be the same length as the base `steps`. On any other length a consumer **MUST** ignore the whole array rather than pair an instruction with the wrong pour. A misaligned instruction is worse than an untranslated one, because it is confidently wrong. A validator **SHOULD** warn. JSON Schema cannot express length equality, so this is a semantic rule. **Only the publisher's own translation belongs here.** This is the same rule that governs the rest of the format: a document states what its source stated. A consumer or transcriber that translates text itself is *authoring*. It makes word choices the publisher never made, and placing them in the publisher's document does not make them the publisher's words. An application can translate for display. It must not write the result back into `localizations` and re-share it. **Matching.** A consumer that chooses a locale **SHOULD** use BCP-47 lookup ([RFC 4647](https://www.rfc-editor.org/rfc/rfc4647) §3.4) rather than exact string equality. Then a request for `en-US` is satisfied by an `en` overlay. With no match it renders the base fields. A consumer that ignores `localizations` entirely does the same, so the field costs nothing to skip. ### Attribution (`author`, `based_on`) `author` credits **who devised the recipe**: a person or organization, in the [Party](#party-object) shape (`{ name, url? }`). It travels inside the document itself, not as an out-of-document sidecar. Attribution must survive a re-share. Once a recipe is copied between apps or pasted into a share URL, only what the JSON carries is still attached to it. `based_on` cites **where the recipe was originally published**: a URL, for a recipe transcribed from a roaster's brew guide, a competition write-up, or a video. Unlike `author`, it names a *publication*, not a person or organization. So it is a bare `string` in `format: uri`, not a `Party`. Three provenance surfaces answer three different questions, and none stands in for another. `author` is *who devised it*. `based_on` is *where it was first published*. The envelope's [`generator`](https://coffeejson.org/docs/spec/02-envelope.md#generator) is *what software wrote this file*, informational only. The first two vary from recipe to recipe, so they live on the Recipe. `generator` cannot vary within a document, so it lives on the envelope. One document can carry all three: a recipe authored by a competition barista, based on the write-up where they published it, exported by whatever app wrote this copy. ### Images & publication date `images` carries **absolute image URLs** for the recipe: the resulting brew, the method in progress. It is always an array. A single image is an array of one, and an empty array is equivalent to absent, like the other optional arrays. The URLs are reference metadata. A consumer can render, proxy, or ignore them, and nothing else in the document depends on them. `date_published` is the ISO 8601 calendar date the recipe was **first published**. It is publication metadata about the recipe as a work. It is distinct from the coffee's [`roast_date`](https://coffeejson.org/docs/spec/04-bean.md) and from [`based_on`](#attribution-author-based_on) (*where* it was published). Together these map to schema.org `image` and `datePublished`. With the recipe's name, they are the properties search-engine rich results require. ```json { "coffeejson": "1.0", "recipes": [ { "title": "4:6 Method", "method": "pour_over", "coffee": { "value": 20, "unit": "gram" }, "water": { "value": 300, "unit": "gram" }, "author": { "name": "Tetsu Kasuya", "url": "https://en.philocoffea.com" }, "based_on": "https://en.philocoffea.com/blogs/blog/coffee-brewing-method" } ] } ``` - **Distinct from `author.url`.** `author.url` is the author's own page (a profile, a site, a channel). `based_on` is the specific place *this recipe* was published. That can be a different page on the same site, as above, or somewhere else entirely (a video, a third party's write-up of a competition routine). - **Distinct from [`generator`](https://coffeejson.org/docs/spec/02-envelope.md#generator).** `generator` records what *software* emitted the JSON. It is informational, a property of the whole document rather than of any one recipe. A consumer **MUST NOT** depend on it. `based_on` records where the *recipe* was first published, regardless of which app later transcribed or exported it. See [Attribution](#attribution-author-based_on) for how the three surfaces relate. - **Licensing is not a per-recipe field.** It is handled at the corpus level, with a repository `LICENSE` plus a page declaration. It is not attached to individual recipe documents. - For structured-data consumers: `author` maps to schema.org's [`author`](https://schema.org/author) (`Person` or `Organization`), and `based_on` maps to schema.org's [`isBasedOn`](https://schema.org/isBasedOn). ### `id` `id` is a **document-local name for one recipe**, so that something outside the `recipes` array can say *which* one it means. It is the recipe-side counterpart to a bean's [`id`](https://coffeejson.org/docs/spec/04-bean.md#id): any non-empty string, unique within the document's `recipes` array, compared **exactly and case-sensitively**. It is never a global identifier, an account, or an inventory key. It exists because **array position is not an identity.** A publication that carries three brew methods is often re-published with them reordered. One can be added in the middle. Everything that named a recipe by its position then silently names a different one. That includes the things a reader keeps: a share link to one method of a bag, a page anchor, a re-shared import. None of those can be made durable by counting. - **Optional, and worth emitting whenever a document carries more than one recipe.** A single-recipe document needs no id. There is nothing to disambiguate, and the document itself is the address. - **Producers SHOULD keep an id stable across re-publication** of the same recipe. An id that changes every time the document is regenerated provides nothing that position did not. - Uniqueness is a semantic rule JSON Schema cannot express. A validator SHOULD show a duplicate as a warning. Duplicate ids make every reference to them ambiguous. A consumer that resolves one **SHOULD** treat the reference as unresolved rather than guess which it meant. - A consumer that does not care which recipe is which can ignore `id`, as it can ignore any member it does not use. ### `bean_ref` `bean_ref` names the coffee this recipe is for. Its value is the [`id`](https://coffeejson.org/docs/spec/04-bean.md#id) of one element of the document's `beans` array, matched **exactly and case-sensitively**. It is a document-local reference, never a global identifier, an account, or an inventory key. It is only needed when a document carries **several** beans. With a single co-located bean the association is implicit. The full resolution rules, including unresolved references, are defined in [Envelope § Association (explicit reference)](https://coffeejson.org/docs/spec/02-envelope.md#association-explicit-reference). ### `recommended` `recommended: true` marks this recipe as the producer's suggested starting point. On a bag, it is *the roaster's recommended way to brew that coffee*. Several recipes MAY carry it, for example one per method: the recommended V60 **and** the recommended AeroPress. - It is a **declared claim** ([Overview, principle 1](https://coffeejson.org/docs/spec/01-overview.md#how-the-data-is-modeled)): the producer's stated suggestion, never a quality assertion about the recipe. - Absent and `false` are equivalent. Both mean *no statement*. A producer **SHOULD** omit the field rather than emit `false`. - It is document-scoped like every CoffeeJSON field. It says nothing about recipes in other documents or in a consumer's library. ### `description` `description` is the recipe's **one- or two-sentence summary**: what it is and why you would brew it ("A relaxed weekend pour-over — bright, sweet, and forgiving."). It is the preview a directory, share card, or search snippet shows (schema.org `Recipe.description`). The recipe carries four human-text surfaces, and each has one job. `title` **names** it. `description` **summarizes** it. [`notes`](#notes) carries **long-form guidance** (character, tips, troubleshooting). Each step's `instruction` says **what to do right now**. Text that answers "should I brew this?" belongs in `description`. Text that answers "how do I get it right?" belongs in `notes`. All four are hinted by `lang`. The Bean's [`description`](https://coffeejson.org/docs/spec/04-bean.md#description) is different in kind: the roaster's own attributed prose about the coffee. ### `notes` `notes` is free-text prose about the recipe as a whole: its character, tips, or troubleshooting ("bright and tea-like; if it tastes sour, grind finer or nudge the water hotter"). It is author→reader content that travels with the recipe, and its language is hinted by `lang`. - It is distinct from `title` (a short label) and from a step's `instruction` (a single step's how-to). Use `notes` for anything about the whole brew. - It is human text. A consumer shows it verbatim and **MUST NOT** parse it for data. Structured parameters always belong in their own fields, never mined from `notes`. - There is no separate "private" notes field. CoffeeJSON carries the recipe's shareable identity, not a consumer's personal log. ### Additions `additions` lists liquids that join the beverage **beyond the brew `water`**: ice for flash-brew, milk or a sweetener stirred into a finished drink, bypass water for an americano-style dilution. Each entry is an [Addition](#addition-object): a `type`, an optional `amount`, and two further optional members, `temperature` and `note`. `type` is an **open registry**, not a closed enum. See [Vocabularies § Addition `type`](https://coffeejson.org/docs/spec/06-vocabularies.md#addition-type) for the recommended set and how an unrecognized value is handled. The motivating v1.0 case is **ice**, for Japanese-style *flash brew*: hot coffee brewed directly onto ice, which chills it at once and melts into the cup. #### An addition without a quantity `amount` is **optional**. The reason is worth stating, because this is the one place in the format where a missing quantity would silently delete a fact the source did state. Sources list ice on the ingredient line without a mass, and some print an unfilled placeholder where the number should be. The presence of an `ice` addition is what marks the whole recipe **iced**. A required `amount` would mean an unquantified ice could not be recorded at all, and the recipe could not be marked iced either. A required quantity would take a semantic flag down with it. ```json "additions": [{ "type": "ice", "note": "listed without a quantity" }] ``` This is the opposite call from `water`, where the requirement is load-bearing. A water-basis recipe with no water states nothing useful. An addition with no amount still states something true and worth carrying. A consumer renders the addition and its `note`, and shows no mass. ```json { "coffeejson": "1.0", "recipes": [ { "title": "Tetsu Kasuya 4:6 Iced", "method": "pour_over", "coffee": { "value": 20, "unit": "gram" }, "water": { "value": 150, "unit": "gram" }, "ratio": 7.5, "water_temp": { "value": 90, "unit": "celsius" }, "additions": [ { "type": "ice", "amount": { "value": 80, "unit": "gram" } } ], "steps": [ { "at_s": 0, "to_water": { "value": 30, "unit": "gram" } }, { "at_s": 40, "to_water": { "value": 60, "unit": "gram" } }, { "at_s": 70, "to_water": { "value": 90, "unit": "gram" } }, { "at_s": 100, "to_water": { "value": 120, "unit": "gram" } }, { "at_s": 130, "to_water": { "value": 150, "unit": "gram" } } ], "finish_s": 180, "notes": "Flash brew: brew hot over 80 g ice so the coffee chills instantly and keeps its aromatics. Half the water of the hot 4:6; the ice melts to dilute. First pour trades sweetness (less) for brightness (more)." } ] } ``` - **The presence of an `ice` addition marks the recipe as iced.** There is no separate `iced` flag. No other `type` carries a defined structural effect. `milk`, `sugar`, `syrup`, `water` (bypass), and `cream` are informational, shown but not acted on. - **`water` and `ratio` describe the brew, not the finished drink.** `water` is what is poured through the bed (150 g above). `ratio` stays `water / coffee` (7.5), the *concentrate* ratio. A consumer MAY compute the effective dilution as `(water + Σ addition amounts) / coffee` (≈ 11.5 above) for display. The format does not store it. - **Additions are independent of `basis`.** An iced pour-over is a water-basis recipe (`water`) plus `additions`. Additions never require or forbid `water` / `yield` / `ratio`. - A consumer that does not model additions **MUST NOT** fail. It ignores the field and, without ice, presents a stronger, un-chilled brew. Iced behavior is best-effort, never required. - A consumer that does model additions but meets a `type` it does not recognize **MUST** still show `amount` (and `temperature` / `note` if present) rather than drop the entry. This is the same forward-compatibility contract as every other field in the format. --- ## Measurement object A quantity with an explicit unit. Used for `coffee`, `water`, `yield`, `water_temp`, `pressure`, and an addition's `amount` / `temperature`. Bean altitude uses the same unit-identifier principle with a range-capable [Altitude](https://coffeejson.org/docs/spec/04-bean.md#altitude-object) object. **When a quantity is a Measurement, and when it is a bare number.** A quantity travels as a `{value, unit}` Measurement when producers state it in different units (mass, temperature, pressure, length), because the unit choice is real information. A quantity with a single canonical unit travels as a **bare number whose name carries the unit**. Durations are seconds (`at_s`, `finish_s`, `preinfusion_s`, `action_duration_s`). Approximate particle size is microns (`microns_approx`). `ratio` is dimensionless. A unit object there would be ceremony with exactly one possible value. ```json { "value": 250, "unit": "gram" } ``` ```json { "min": 18.5, "max": 19, "unit": "gram" } ``` | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `value` | number | one of | A single stated magnitude. | | `min` | number | one of | Lower bound, when the source states a window. | | `max` | number | one of | Upper bound, when the source states a window. | | `unit` | string | yes | Mass: `gram`, `ounce`. Volume (brew `water` only): `milliliter`. Temperature: `celsius`, `fahrenheit`. Pressure: `bar`. See [Water quantity](#water-quantity). | A Measurement **MUST** carry `unit` and at least one of `value`, `min`, or `max`. This is the same rule as [Altitude](https://coffeejson.org/docs/spec/04-bean.md#altitude-object), which carries ranges because origins are commonly listed as elevation bands. ### Stated windows Brewing guidance is often published as a window rather than a point: an espresso yield of 32–34 g, a dose of 25–45 g for a recipe that scales with the press, a brew temperature of 92–94 °C. A window is what the author stated. The format carries it rather than force a producer to invent a point. - **State the window or the point, never both.** A Measurement that carries `value` beside `min`/`max` is contradictory. Producers **MUST NOT** emit it, and **the schema rejects it** (`dependentSchemas`: a present `value` forbids `min`/`max`). The one contradiction the schema cannot express is `min` greater than `max`. Validators **SHOULD** warn on that. - **One-sided windows are legal.** `{ "min": 25, "unit": "gram" }` says "at least 25 g" and nothing about an upper bound, which is what some sources state. - **A consumer that needs a single number derives one and says so.** It can take the midpoint for display, or `min` for a schedule. It **MUST NOT** present a derived point as the author's number. This is the same rule as [Scaling](#scaling): a consumer that applies its own model owns the result. - **`ratio` is unaffected.** It remains a bare dimensionless number that describes the relationship between `water` and `coffee`. When both are windows it is often the thing that *couples* them. A French press guide that states 25–45 g of coffee to 375–675 g of water states one ratio of 15 across the range. `ratio` carries that coupling without loss, where two independent windows would not. - **Durations do not take windows.** `at_s`, `finish_s`, `preinfusion_s` and `action_duration_s` are bare numbers by design (see above). A published time window belongs in `notes` or the step's `instruction`. ### Water quantity Brew water is the one input publishers state **either by mass or by volume**, because it is a liquid of known density. A guide that prints `お湯 92℃ 225cc` beside a dose in grams is ordinary, not exotic. So [`water`](#fields) and a step's [`to_water`](#step-object) accept `milliliter` in addition to `gram` and `ounce`. The ounce is the avoirdupois mass ounce. A source that states *fluid* ounces states a volume, which belongs in `milliliter` (see [Units](https://coffeejson.org/docs/spec/06-vocabularies.md#units)). Everything else stays a mass. A dose, a beverage `yield`, and an addition's `amount` are masses in every source the format has met. ```json { "value": 225, "unit": "milliliter" } ``` **No conversion between the two is defined.** Water's density varies with temperature. 225 mL is 225 g only near 4 °C, and at a stated 92 °C it is ≈216.8 g, a 3.6 % error, coarser than the precision such a guide is written to. A consumer that needs the other kind applies its own model. It **MUST NOT** present the result as the author's figure, exactly as under [Scaling](#scaling). Producers emit what their source stated. A recipe whose `water` is a volume states no mass ratio, so it omits [`ratio`](#ratio). See there. - Producers **MUST** emit canonical unit identifiers, not localized display symbols (`gram`, not `g`; `celsius`, not `°C` or `C`). - Consumers **MUST** convert any unit they recognize into their own canonical store. - Consumers **MUST NOT** show the wire `unit` string directly. They render localized labels and symbols with platform measurement APIs or their own locale tables. - If a consumer does not recognize the `unit`, it **MUST** treat the measurement as absent. It **MUST NOT** guess. A value silently stored in the wrong unit is worse than nothing. - A Measurement's `value` is **strictly positive** for mass and pressure. A zero-gram dose or a zero-bar pressure states nothing, and the schema rejects it. Temperatures can be zero or below, and a zero-second `at_s` offset is real data. The full unit vocabulary, including conversion factors, is in [Vocabularies § Units](https://coffeejson.org/docs/spec/06-vocabularies.md#units). --- ## Filter object What the water passes through on its way out of the bed. Publishers state it on brew guides far more often than on product pages, and it changes the cup. Paper retains oils and fines, metal lets them through, and cloth sits between. ```json { "material": "paper", "label": "Chemex bonded — 3-ply on one side" } ``` | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `material` | string (enum) | yes | `paper` · `metal` · `cloth` · `other`. Unknown → `other`. See [Vocabularies § Filter material](https://coffeejson.org/docs/spec/06-vocabularies.md#filter-material). | | `label` | string | no | The filter as the source names it, when that says more than the material. Human text; language hinted by `lang`. | `material` is the queryable, portable part. `label` carries what the enum flattens: a product (`"V60-02"`), a form (a tea bag), or the brewer's own part (a plunger mesh). The specific product is usually implied by the `brewer`, which is why the label is free text rather than a registry. Three things that look like filter data and are not: - **Rinsing** is a [step](#step-object), not a property of the filter. It is a `prep` step with an instruction, where a guide can say what to do with the rinse water. - **A negation** ("this brewer doesn't use a paper filter, so the cup is rich with oils") is prose for [`notes`](#notes). State the filter positively, for example the metal one in a French press, and let the prose keep the *why*. - **A choice** ("either paper or mesh") is a guide that describes a family rather than one brew. Omit `filter` and say so in `notes`. A recipe states the filter it calls for, not the options. ## Gear object One shape for any piece of equipment: a grinder, a brewer, or a basket. It carries a canonical **id** for matching, a **variant** naming which one of that family was used, normalized **brand** / **model** for off-registry gear, and a display **label** for fallback. ```json { "id": "hario-v60", "variant": "02" } { "id": "custom", "brand": "Modbar", "model": "AV", "label": "Modbar AV" } ``` The registry entry names the **family**; `variant` names **which one of it**. That split is why `hario-v60` covers every V60 ever made without the registry growing a row per size, material and generation. | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `id` | string | yes | Canonical registry slug — lowercase kebab-case, schema-enforced — or the literal `"custom"` for off-registry gear. This is the field other apps match on (byte-exact), which is why the grammar is strict: a mis-cased id would validate yet match nothing. | | `brand` | string | no | Normalized brand, for example `Hario`. | | `model` | string | no | Normalized model, for example `V60`. | | `variant` | string | no | The maker's own variant designation for this instance, as printed — a size (`02`, `185`, `MDN-41`), a material (`ceramic`), a generation (`Gen 2`). Free text, never an enum: the varying axis differs per family, so no closed list fits the next one. Same call `grind.setting` makes. | | `label` | string | no | Display string / fallback. Required when `id` is `"custom"`, and schema-enforced there, since there is no registry entry to localize. | Consumer behavior: - For a **known** `id`, a consumer **SHOULD** prefer its own localized label over the producer's `label`, so the same gear reads consistently in the consumer's locale — and **SHOULD** render `variant` beside it, since the registry cannot supply it and dropping it loses what the document knew. - With a known `id`, a producer **SHOULD** omit `brand` and `model`: the registry is authoritative for both, and repeating them only drifts from it. - For `id: "custom"`, a consumer shows the producer's `label` verbatim. - An unknown (non-`"custom"`) `id` is treated like `"custom"`: fall back to `label`, then to `brand` / `model`. A consumer **MUST NOT** fail on an unrecognized `id`. The registry of known slugs is curated, open, and non-exhaustive by design. `id: "custom"` plus `label` always works, so missing coverage never blocks a share. See [Vocabularies § Gear registry](https://coffeejson.org/docs/spec/06-vocabularies.md#gear-registry). --- ## `method` vocabulary `method` is the brewing **technique**, distinct from the `brewer` **device**. A V60 (device) is used with a `pour_over` (technique). An AeroPress (device) can be used `immersion` or `pour_over`. Keeping them separate prevents conflating "what I used" with "how I used it." The v1.0 values are stable machine ids. Consumers localize them: `pour_over` · `immersion` · `aeropress` · `french_press` · `moka` · `cold_brew` · `siphon` · `cezve` · `drip` · `capsule` · `espresso` · `other` Notes: `drip` is a batch / filter machine. `capsule` is a pod system. `cezve` is ibrik / Turkish. A consumer that meets an unrecognized value **MUST** treat it as `other`. New methods can be added in a minor version. The full table is in [Vocabularies § method](https://coffeejson.org/docs/spec/06-vocabularies.md#method). **`espresso` sets `basis: "yield"`.** `method` is descriptive. The recipe's `basis` carries the structural rule: a `basis: "yield"` recipe (espresso's basis) REQUIRES `yield` and **MUST NOT** carry `water` or `ratio`, so its required numbers always mean what they say. See [Espresso (dose : yield)](#espresso-dose--yield). --- ## Grind object `setting`, `microns_approx`, and `size` are **three views of one target grind** at different precisions and portabilities. A consumer shows the most specific axis it understands: `setting` when the reader has the sender's grinder, else `microns_approx`, else `size`. It need not reconcile them. They are independent expressions, not derivations of each other. ```json { "grinder": { "id": "comandante-c40", "brand": "Comandante", "model": "C40", "label": "Comandante C40" }, "setting": "22 clicks", "microns_approx": 700, "size": "medium_fine" } ``` | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `grinder` | [Gear](#gear-object) | no | The grinder used. | | `setting` | string | no | The setting **as expressed on that grinder** ("22 clicks", "6.5", "stepless 1.4 turns"). Free text. | | `microns_approx` | number | no | Approximate particle size in microns (strictly positive). Explicitly approximate — the only roughly-portable axis. | | `size` | string (enum) | no | Qualitative coarseness on the standard seven-level scale (ordered). See [Grind `size` vocabulary](https://coffeejson.org/docs/spec/06-vocabularies.md#grind-size). Unknown → ignore the field, preferring `setting` / `microns_approx` when present. | Grind is the one place CoffeeJSON refuses to be clever: - `setting` is **free text** and **MUST NOT** be coerced to a number. Grinder scales are not portable. "22 clicks" means nothing on a different grinder, so the setting is preserved exactly as the sender expressed it. - `microns_approx` is the only axis that is even loosely comparable across grinders, and it is explicitly approximate. A consumer **MUST NOT** automatically apply it to a different grinder's dial. - `size` sits **beside** `setting` and `microns_approx`, not above them. It is the qualitative read ("medium-coarse") on a standard scale, comparable across grinders in a way `setting` never is, and coarser-grained than `microns_approx`. A consumer that meets an unrecognized `size` value, or no `size` at all, falls back to `setting` / `microns_approx` for display. - **No cross-grinder conversion is defined or expected in v1.0.** A consumer shows the sender's `grinder` / `setting` / `microns_approx` / `size` as stated and lets the user dial in their own equipment. Structured grind data means a conversion layer can be added later with no format change, and the format never produces a confidently-wrong number today. --- ## Step object A brew is an **ordered sequence of actions**. Most steps are timed pours, but a step can be an untimed prep action or a manipulation. **Array order is authoritative.** `at_s` is an optional timing annotation, and `to_water` applies only to pour-type steps. See [The data-guard rule](#the-data-guard-rule) for exactly how a consumer tells the difference. Across the steps that carry them, a producer **SHOULD** emit `at_s` and `to_water` non-decreasing in array order: time runs forward and the scale reading only rises. A consumer **MUST NOT** reorder steps to repair a schedule that does not — it keeps array order and shows the numbers it was given, so a recipe never plays differently in two apps. The everyday pour-over step is `{ "at_s", "to_water" }`. `kind` defaults to `pour`, so a common recipe is a simple list of *(time, target weight)*. ```json { "kind": "pour", "at_s": 30, "to_water": { "value": 150, "unit": "gram" }, "instruction": "slow circle pour" } { "kind": "bloom", "at_s": 0, "to_water": { "value": 45, "unit": "gram" }, "instruction": "saturate, swirl gently" } { "kind": "prep", "instruction": "rinse filter, preheat dripper" } { "kind": "flip", "instruction": "invert the AeroPress" } { "kind": "press", "at_s": 90, "instruction": "press gently over ~20s" } { "kind": "distribute", "instruction": "WDT until the bed is level" } { "kind": "pull", "instruction": "line pressure to 3.5 s, then 9 bar" } ``` | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `kind` | string (enum) | no | Default `pour`; unknown → `other`. `bloom` is pour-type — see [Bloom](#bloom). The value list lives in **one** place: [Vocabularies § Step `kind`](https://coffeejson.org/docs/spec/06-vocabularies.md#step-kind). | | `at_s` | number | no | Seconds from brew start to cue this step. **Absent = sequential / user-paced** (prep, flips). | | `to_water` | Measurement | no | **Cumulative** water in the cup by the **end** of this step — the scale's target reading. Same `{value,unit}` shape as `water`. Pour-type steps only. | | `instruction` | string | no | Free-text how-to ("center then circle", "agitate", "shake the dripper"). Human text — see `lang`. | | `label` | string | no | Present **only if** the author explicitly customized it. See the rule below. | | `action_duration_s` | number | no | How long this step's action takes, in seconds. See [Action duration](#action-duration). | ### Cumulative water `to_water` is **cumulative**, not per-step. It is the total water in the cup by the *end* of the step, the weight the scale should read at that point. A [bloom](#bloom) to 30 g followed by a pour to 150 g means the second pour ends at 150 g, having added 120 g. A consumer shows the cumulative target and, if it likes, the per-pour amount (the difference from the previous step). Cumulative targets are how scale-based pour-over is executed: pour until the scale reads the number. ### The data-guard rule Whether a step is **water-bearing** (belongs in the pour schedule) is decided by its **data**, not its `kind`. Any step that carries both `at_s` and a usable `to_water` is water-bearing, whatever `kind` says. A consumer **MUST** schedule water off that pair and **MUST NOT** gate scheduling on a `kind` allowlist. This is what makes new pour-type kinds forward-compatible for free. `bloom` (below) carries `at_s` + `to_water` exactly like `pour`, so a consumer that follows this rule already schedules it correctly with no code change. The same holds for any pour-type kind a future minor version adds. The corollary is the safety net. A consumer that does not recognize a `kind` has nothing to consult but the data. An unrecognized pour-type kind can at most **degrade to label-only** (shown through its `instruction`, not scheduled). It can never produce broken or miscounted water math, because `kind` is never part of how the number is computed. ### Bloom `bloom` is a **pour-type** step kind for the initial pre-wet: a short pour (usually around twice the coffee's mass in water) that saturates the grounds and lets trapped CO₂ escape before the main pour schedule continues. It carries `at_s` + `to_water` exactly like `pour`: ```json { "kind": "bloom", "at_s": 0, "to_water": { "value": 45, "unit": "gram" } } ``` Any consumer that follows [the data-guard rule](#the-data-guard-rule) picks it up with no special-casing. An ordinary `pour` (or kind-defaulted) step at `at_s: 0` is also valid for the same pre-wet. Naming it `bloom` lets a producer state the step's *purpose*. A consumer can then render its own localized "Bloom" label from the kind rather than the author serializing one. This is the same principle as [the derived-label rule](#the-derived-label-rule) below. ### Action duration `action_duration_s` states how long the step's **action** itself takes, in seconds. It is a single value, distinct from `at_s` (*when* the step starts). It applies to any step whose action has a duration worth recording: a slow controlled pour, a `press` (the plunge), a `stir`. Paired with the water delta between this step and the previous one (this step's `to_water` minus the previous step's), `action_duration_s` yields the pour **rate** in g/s. That is useful when cadence matters as much as total volume. A consumer that does not compute rate ignores the field. Deriving it is optional presentation, never required to render the schedule. ### The derived-label rule `label` carries a *customized* name only. A **derived or default** label ("Bloom", "Pour 2", "Drawdown") **MUST** be serialized as absent, so that each consumer renders its own localized default from the step's position and kind. Emitting "Bloom" would freeze one language into the data. Omitting it lets a consumer show "Bloom", "ブルーム", or "Floração" as appropriate. This rule is a direct consequence of principle 2 ([locale-neutral on the wire](https://coffeejson.org/docs/spec/01-overview.md)). ### Mixed-capability consumers Array order **MUST** be preserved by every consumer. A consumer that only models timed pours builds its schedule with [the data-guard rule](#the-data-guard-rule): every step with `at_s` + a usable `to_water`, whether `pour`, `bloom`, or a kind it has never heard of. It shows every other step **read-only** instead, for example its `instruction` shown but unscheduled. Such a consumer **MUST NOT** fail on a step kind it does not implement. An unmodeled step is shown, never an error. The espresso kinds (`distribute` (WDT), `tamp`, `pull`) are steps like any other. None of them carry `to_water`, because the shot's numbers (yield, pressure, time) live at the recipe level, never on a step. So the data-guard rule surfaces them read-only, and a consumer that does not model espresso needs no espresso-specific check to get that right. --- ## Addition object A liquid added to the brew beyond the brew `water`. `type` is an **open registry** (recommended: `ice`, `milk`, `sugar`, `syrup`, `water`, `cream`), not a closed enum. The object stays small on purpose, and a new kind of addition is a new string, never a schema change. `temperature` and `note` are optional members for when something beyond the amount is worth recording. ```json { "type": "ice", "amount": { "value": 80, "unit": "gram" } } ``` ```json { "type": "milk", "amount": { "value": 100, "unit": "gram" }, "temperature": { "value": 65, "unit": "celsius" }, "note": "oat" } ``` | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `type` | string | yes | What is added — an **open registry**, not a closed enum: any value is valid. Recommended: `ice` · `milk` · `sugar` · `syrup` · `water` · `cream`. See [Addition `type` vocabulary](https://coffeejson.org/docs/spec/06-vocabularies.md#addition-type). | | `amount` | [Measurement](#measurement-object) | no | How much is added, by mass. Recommended unit `gram`. Omitted when the source lists an addition without a quantity — see [An addition without a quantity](#an-addition-without-a-quantity). | | `temperature` | [Measurement](#measurement-object) | no | Temperature of the added liquid, where meaningful (for example steamed milk). Recommended unit `celsius`. | | `note` | string | no | Free-text detail — brand, prep, sweetener kind. Human text — see `lang`. | - `ice` denotes coffee brewed onto ice (Japanese-style flash brew). The ice both chills the brew and melts into the beverage, and its presence marks the recipe iced. See [Additions](#additions). - Additions do not change the meaning of `water` or `ratio`. No cross-field arithmetic is implied. A consumer that wants a total-dilution figure derives it. - A consumer that does not recognize a `type` value handles it **generically**: it shows `type` and `amount` (and `temperature` / `note` if present) without special-casing it, and it **MUST NOT** fail. Only `ice` carries a defined behavioral effect (marking the recipe iced). Every other value, known or not, is informational. --- ## Party object A person or organization credited on a document. One shape for every credit: recipe [`author`](#attribution-author-based_on), bean [`roaster`](https://coffeejson.org/docs/spec/04-bean.md#fields), and each entry of an origin item's [`producers`](https://coffeejson.org/docs/spec/04-bean.md#who-produced-it). A consumer renders a producer with the code it already uses for a roaster or an author, and a structured-data exporter emits the same Person / Organization node. ```json { "name": "Tetsu Kasuya", "url": "https://en.philocoffea.com" } ``` | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `name` | string | yes | Display name, as the source writes it. Never empty. | | `url` | string (URI) | no | The party's own page — a profile, site, or channel. | | `type` | string (enum) | no | `person` · `organization`, when known. Absent/unknown → infer from the crediting field. See [Party `type`](https://coffeejson.org/docs/spec/06-vocabularies.md#party-type). | | `role` | string | no | The part this party played, on any credit. An [open registry](https://coffeejson.org/docs/spec/06-vocabularies.md#producer-role). | ## Field mapping summary | Concept | Field | Canonical form | | --- | --- | --- | | Coffee dose | `coffee` | `gram` measurement | | Brew water | `water` | `gram` measurement (not on a yield-basis recipe) | | Beverage yield | `yield` | `gram` measurement (required when `basis` is `yield`) | | Ratio | `ratio` | bare number (water ÷ coffee; not on a yield-basis recipe) | | Brew pressure | `pressure` | `bar` measurement | | Pre-infusion | `preinfusion_s` | seconds (number) | | Basket | `basket` | [Gear](#gear-object) | | Water temperature | `water_temp` | `celsius` measurement | | Technique | `method` | machine id ([vocabulary](https://coffeejson.org/docs/spec/06-vocabularies.md#method)) | | Stated-quantity basis | `basis` | machine id — `water` (default) · `yield` ([vocabulary](https://coffeejson.org/docs/spec/06-vocabularies.md#basis)) | | Device | `brewer` | [Gear](#gear-object) | | Grinder + setting | `grind` | [Grind](#grind-object) | | Pour schedule | `steps` | ordered [Step](#step-object) array | | Recipe author | `author` | [Party](#party-object) | | Original publication | `based_on` | URI string (schema.org `isBasedOn`) | | Linked coffee | `bean_ref` | the [`id`](https://coffeejson.org/docs/spec/04-bean.md#id) of a bean in this document | | Producer's pick | `recommended` | boolean; omitted unless `true` | | Recipe notes | `notes` | free-text prose (human text) | | Added liquids | `additions` | [Addition](#addition-object) array — open `type` registry | --- # Bean A **Bean** describes the identity of a coffee: where it comes from, how it was processed, how it was roasted, and what the roaster says it tastes like. In v1.0 a Bean is a **top-level entity**, carried as an element of the `beans` array in the [document envelope](https://coffeejson.org/docs/spec/02-envelope.md). A one-element `beans` array shares a single coffee. A multi-element array carries a catalog of distinct coffees. Both are independent of any recipe. When a one-element `beans` array is co-located with `recipes`, those recipes are *for* that coffee. This is the [bag-to-brew](https://coffeejson.org/docs/spec/02-envelope.md#association-co-location) case: the coffee and the way to brew it in one document. A **blend is one Bean**, a single element of `beans`. It is expressed as an [origin](#origin-object) of `type: "blend"` with several `items`, or with no `items` at all when the components are not published. A multi-element `beans` array is for several *distinct* coffees, never the components of a single blend. Every field is optional. A Bean can be as thin as a roaster name or as rich as a full origin record. It carries no inventory or personal state (principle 4, [Overview](https://coffeejson.org/docs/spec/01-overview.md)): no bag weight, no purchase date, no personal rating. ```json { "name": "Nano Challa", "roaster": { "name": "Example Roastery" }, "url": "https://example.com/coffees/nano-challa", "origin": { "type": "single", "items": [ { "country": "ET", "region": "Guji", "producers": [{ "name": "Nano Challa cooperative", "role": "cooperative" }], "altitude": { "min": 1900, "max": 2100, "unit": "meter" }, "harvest_time": "Oct–Dec 2025" } ] }, "process": ["washed"], "drying_method": "raised_bed", "varietals": ["Heirloom"], "roast_level": "light_medium", "roast_agtron": 65, "roast_date": "2026-06-20", "decaf": false, "form": "bean", "preferred_extraction": "filter", "certifications": ["organic", "fair_trade"], "roaster_notes": ["blueberry", "dark chocolate", "floral"], "description": "A juicy washed heirloom lot — bright, floral, and honey-sweet." } ``` ## Fields | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `id` | string | no | Document-scoped identifier so a recipe can reference this bean via [`bean_ref`](https://coffeejson.org/docs/spec/03-recipe.md#bean_ref). Needed only in multi-bean documents. See [`id`](#id). | | `name` | string | no | The coffee's product name. | | `roaster` | [Party](https://coffeejson.org/docs/spec/03-recipe.md#party-object) | no | The roasting company, as a party — `{ name, url?, type? }`. Absent `type` defaults to organization by role. | | `url` | string (URI) | no | The roaster's canonical product page for this coffee. A reference/pointer, **not** a claim about the coffee — see [Provenance tiers](#provenance-tiers-within-bean). | | `images` | array of string (URI) | no | Image URLs for the coffee — typically the roaster's product photography. Always an array; omit or empty when none. Reference metadata, like `url`. | | `origin` | [Origin](#origin-object) | no | Single origin or blend. | | `process` | array of string (enum) | no | Post-harvest processes — a **set** (order as stated, not significant). See [`process` vocabulary](https://coffeejson.org/docs/spec/06-vocabularies.md#process) and [Processes are a set](#processes-are-a-set). | | `drying_method` | string | no | Post-harvest **drying** method, distinct from `process` (for example `raised_bed`, `patio`, `mechanical`). Free string. See [Drying method](#drying-method). | | `varietals` | array of string | no | Canonical varietal names / slugs. See [Varietals](#varietals). | | `roast_level` | string (enum) | no | `light` · `light_medium` · `medium` · `medium_dark` · `dark` · `extra_dark`. | | `roast_agtron` | number | no | Agtron Gourmet number, 0–100 (light-high: ~95 very light … ~25 very dark) — the measurable counterpart to `roast_level`. | | `roast_date` | string (ISO 8601 date) | no | for example `"2026-06-20"`. | | `rest_days` | object | no | `{ min?, max? }` — the window, in days from roast, in which the roaster recommends brewing. See [Rest window](#rest-window). | | `production_roaster` | string | no | The roasting **machine** the coffee is produced on, as printed (for example `"Diedrich CR-70"`). Free string. See [Production roaster](#production-roaster). | | `decaf` | boolean | no | A coffee attribute, not inventory. Absent = unstated; `false` is the explicit claim that the coffee is not decaf. | | `form` | string (enum) | no | Physical form the coffee is sold/prepared in: `bean` · `ground` · `pod` · `drip_bag` · `instant` · `other`. Unknown → `other`. See [`form` vocabulary](https://coffeejson.org/docs/spec/06-vocabularies.md#form). | | `preferred_extraction` | string (enum) | no | `espresso` · `filter` · `omni` — the style the roaster developed the roast for. See [Preferred extraction](#preferred-extraction). | | `certifications` | array of string | no | Roaster-declared certifications / production claims (for example `organic`, `fair_trade`, `rainforest_alliance`). Free strings. See [Certifications](#certifications). | | `roaster_notes` | array of string | no | Tasting descriptors **as claimed by the roaster**. See [Roaster notes](#roaster-notes). | | `description` | string | no | The roaster's own prose about the coffee, as written. Human text. See [Description](#description). | | `lang` | string (BCP-47) | no | Language of the Bean's human text fields (`description`, free-text notes). Same well-formed BCP-47 tag as on the [Recipe](https://coffeejson.org/docs/spec/03-recipe.md). A hint only. | | `localizations` | object | no | The roaster's own translations of this coffee's human text, keyed by BCP-47 tag — the bilingual bag. Requires `lang`. See [Localizations](#localizations). | ## Provenance tiers within Bean A Bean spans two of the format's three [provenance tiers](https://coffeejson.org/docs/spec/01-overview.md#how-the-data-is-modeled): - **Tier 2 — declared claims** (roaster claims): `origin` (including each item's `name`, `process`, `harvest_time`, and blend `percentage`), `process`, `drying_method`, `varietals`, `roast_level`, `roast_agtron`, `roast_date`, `rest_days`, `production_roaster`, `decaf`, `form`, `preferred_extraction`, `certifications`. These are normalized to factual, queryable forms (ISO country codes, meters, the Agtron number, an ISO date). They remain *claims* on the bag: normalized, not independently verified. `certifications` in particular is a *stated* claim, never an independent audit. - **Tier 3 — attributed subjective** (sensory): `roaster_notes` and `description`, always attributed to the roaster, never asserted as bare fact. `id`, `url`, `images`, and `lang` sit **outside** the tiers. They are reference metadata, not a fact, claim, or opinion *about the coffee*. `id` is a document-local handle so a recipe can point at this bean ([below](#id)). `url` is a pointer to the roaster's product page and `images` to its product photography. A consumer can follow them or ignore them. They assert nothing about the coffee's identity or taste. `lang` hints the language of the human text fields, as on the Recipe. A drinker's *personal* rating is Tier 3 too, but CoffeeJSON does **not** carry it (principle 4: that is personal state, not the coffee's identity). ### `id` `id` exists for exactly one job: it lets a recipe in the **same document** name this bean via [`bean_ref`](https://coffeejson.org/docs/spec/03-recipe.md#bean_ref). It is necessary only when `beans` carries several coffees, because a single co-located bean needs no identifier (see [Envelope § Association](https://coffeejson.org/docs/spec/02-envelope.md#association-co-location)). - Any non-empty string is a valid `id`. A short slug (`"nano-challa"`) reads well in a URL-embedded document, but no format is imposed. - Every `id` present **MUST** be unique within the document's `beans` array, compared **case-sensitively**. If several beans share an id, the document is malformed. A consumer **MUST NOT** fail, and **SHOULD** treat references to that id as unresolved. - An `id` is a **local label, not an identity**. It implies no registry and no account, and it is not stable across documents. Two documents can reuse the same string for different coffees. Anything that would only make sense as a global key is inventory state and out of scope (principle 4, [Overview](https://coffeejson.org/docs/spec/01-overview.md)). ### Roast level and Agtron `roast_level` is the categorical claim (`light` … `extra_dark`). `roast_agtron` is the measurable counterpart on the Agtron Gourmet scale. They are complementary, not redundant. A producer **MAY** emit either or both. When both are present and they disagree, a consumer **SHOULD** prefer `roast_agtron` for any numeric comparison and use `roast_level` for display. It **MUST NOT** reject the document. The six values follow the scale specialty coffee is sold and filtered with at retail. Marketplace facets run *Light / Light-Medium / Medium / Medium-Dark / Dark / Extra-Dark*. A roaster's own scale name ("Expressive Dark") is not normalized away. It stays in `description`, and `roast_level` carries the comparable category. A roaster's own *number* is treated the same way, and the rule is stricter because the failure is silent. `roast_agtron` carries a value stated on the Agtron Gourmet scale and nothing else. A bounded house dial, or an Agtron-derived scale the roaster describes as adapted, is a different measurement in the same shape. A producer **MUST NOT** emit it in this field. The hazard is that such a number often falls *inside* 0–100 and so validates. No consumer can tell it from a Gourmet reading, and the two commonly disagree about which end of the scale is dark. Omit the field unless the source states the scale, and keep the roaster's figure in `description` in their words. A house number is comparable only within that one roaster's catalog, so nothing comparable is lost. Trade names ("Full City+"), house scale names, and marketplace tiers all resolve to `roast_level` by the roaster's own stated intent. This specification publishes no conversion table, because none is authoritative. Trade names have no governing body, roasters disagree, and Agtron readings vary with sample preparation. ### Rest window Roasters publish when to drink a coffee, not only when it was roasted. Often it is a labeled field on every bag (`エイジング`, "aging"), sometimes a sentence ("rest at least 14 days"). It is a brewing precondition, and it is not derivable from `roast_date`. How long a coffee needs to degas depends on the roast and the roaster's judgment, not on the calendar. ```json "rest_days": { "min": 14 } ``` ```json "rest_days": { "min": 14, "max": 60 } ``` Both bounds are day counts from `roast_date`, and at least one **MUST** be present. `min` is the degassing period. Brew before it and the coffee is still outgassing. `max` is where the roaster stops recommending it. A roaster who states only one bound states only one. Nothing is inferred from the other end. A roaster can publish a *peak* inside a wider limit ("best at 2–3 weeks, good within 2 months"). The bounds carry the recommended window, and the peak stays in `description`. The format carries the window the roaster stands behind, not a three-point curve. This is the coffee's own claim about itself, not a shelf life. It says nothing about a particular bag's age, which would be inventory state (principle 4). ### Production roaster `production_roaster` names the roasting **machine** the coffee is produced on: `"Diedrich CR-70"`, `"Loring S70 Peregrine"`, `"Probat P25"`. Roasters increasingly print it beside process and drying method as production provenance. It is a Tier-2 declared claim and a **free string** in v1.0 (no machine registry). It is distinct from `roaster`, the company. A consumer shows it verbatim or ignores it. ### Preferred extraction `preferred_extraction` states what the roaster **developed the roast for**: `espresso`, `filter`, or `omni` (both, on purpose). Roasters increasingly print it as a structured attribute ("Preferred Extraction: Espresso"), and bean-importing apps model the same concept (Beanconqueror's *bean roasting type*). It is a Tier-2 **declared claim about intent**, never a restriction. Any coffee can be brewed any way. It says nothing about which co-located recipe to prefer. That is the recipe's [`recommended`](https://coffeejson.org/docs/spec/03-recipe.md#recommended) flag. Unknown value → ignore the field. See [Vocabularies § `preferred_extraction`](https://coffeejson.org/docs/spec/06-vocabularies.md#preferred_extraction). ### Varietals `varietals` is an array of canonical varietal names or slugs, for example `Bourbon`, `Caturra`, `Catuai`, `Typica`, `Gesha`, `SL28`, `Heirloom`, `Pacamara`. A shared registry maps aliases and breeding codes (for example `BM139`, `CAT129` / `Nyika`, `H1`) to a canonical name. A consumer that does not recognize a value **MUST** pass it through unchanged rather than drop it. See [Vocabularies § Varietal registry](https://coffeejson.org/docs/spec/06-vocabularies.md#varietal-registry). ### Roaster notes `roaster_notes` are tasting descriptors **as claimed by the roaster**: a Tier-3 attributed claim, never a bare assertion that the coffee *is* blueberry. In v1.0 they are free strings. A later revision may align them to the Coffee Taster's Flavor Wheel lexicon, published by the Specialty Coffee Association and World Coffee Research (see [Versioning § Reserved extensions](https://coffeejson.org/docs/spec/07-versioning.md#reserved-extensions)). Until then, a consumer **MUST** accept arbitrary strings. `roaster_notes` is distinct from a structured cupping score, which is a reserved `tasting` extension and not part of v1.0. ### Description `description` carries the roaster's own prose about the coffee, the paragraph on the bag or product page, **as written**. Like `roaster_notes` it is Tier-3 attributed text: the roaster's voice, never normalized, never asserted as fact. A consumer **SHOULD** show it attributed, because it is marketing prose, not a neutral summary. It **MUST NOT** parse it for structured data the document also carries elsewhere. `lang` hints its language. Structured facts (origin, process, notes) belong in their own fields, not only in the prose. ### Drying method `drying_method` records **how the coffee was dried** after processing, for example `raised_bed` (African / raised beds), `patio`, `covered_patio`, `mechanical` (guardiola / drum). It is distinct from `process`, the post-harvest ferment/wash decision. Two washed coffees can be patio-dried and raised-bed-dried, and roasters increasingly print it as structured provenance rather than prose. In v1.0 it is a **free string** with no controlled vocabulary. A consumer that does not recognize a value **MUST** pass it through unchanged. A later revision may introduce a `drying_method` registry, a data change rather than a version bump (see [Versioning § Registry governance](https://coffeejson.org/docs/spec/07-versioning.md#registry-governance)). ### Certifications `certifications` is an array of **roaster-declared** certification or production-claim strings, for example `organic`, `fair_trade`, `rainforest_alliance`, `kosher`, `biodynamic`, `regenerative_organic`. In v1.0 they are **free strings**. A later vocabulary may normalize common claims. A certification here is a *stated claim on the bag* (Tier 2), never an independent audit. A consumer **MUST NOT** present it as verified, and **MUST** pass unknown values through unchanged. ### Localizations The bilingual bag: a roaster who prints the same coffee in two languages carries the second in `localizations`, keyed by BCP-47 tag. A Bean localization carries `name`, `description`, and `roaster_notes`, the roaster's *wording*. ```json { "name": "エチオピア イルガチェフェ", "lang": "ja", "roaster": { "name": "Example 焙煎所" }, "origin": { "items": [ { "country": "ET", "region": "Yirgacheffe", "process": ["washed"] } ] }, "roaster_notes": ["ジャスミン", "ピーチ", "紅茶"], "localizations": { "en": { "name": "Ethiopia Yirgacheffe", "roaster_notes": ["Jasmine", "Peach", "Black tea"] } } } ``` The shared rules are stated once under [Recipe § Localizations](https://coffeejson.org/docs/spec/03-recipe.md#localizations) and apply here unchanged. Those are: `lang` required, only the publisher's own translation, BCP-47 lookup matching, and no non-text member in the authoring schema. What is specific to a Bean: - **The coffee's identity never varies.** `origin`, `process`, `varietals`, `roast_level`, `altitude`, and dates never appear in a localization. None of them change with the language the bag is printed in. A Yirgacheffe is grown in the same place in every language. - **`roaster_notes` is replaced whole, not matched item by item.** Descriptor lists get rewritten in translation rather than mapped one-to-one. A roaster who prints four notes in Japanese and three in English has published exactly that. Pairing them by position would invent a correspondence they never claimed. This is the one exception to the positional rule for a recipe's `steps`. There, position *is* the correspondence: step 2 is step 2 in every language. --- ## Origin object ```json { "type": "single", "items": [ { "country": "CO", "region": "Huila", "producers": [{ "name": "…" }], "altitude": { "min": 1700, "max": 1900, "unit": "meter" } } ] } ``` | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `type` | string (enum) | no | `single` · `blend`. Defaults to `single` when there is one item, `blend` when there are several. State it explicitly when `items` is absent, where it carries the only origin fact known. | | `items` | array of [OriginItem](#originitem-object) | no | One item for a single origin; several for a blend (proportions may be unknown). Omit when the components are not published; an absent `items` means **unknown**, never none. | `type` is a convenience label. A consumer **SHOULD** derive the effective type from `items.length` when `type` is absent (one item → single, more → blend). Both can be omitted independently, and `type` alone is a complete statement. Blends routinely do not publish their component origins. A producer that knows only "this is a blend" **MUST** be able to say exactly that, as `{ "type": "blend" }`. It does not invent components or discard the fact. This is the one case where `type` is not derivable and so not redundant. An **empty** `items` array is invalid. Absent and empty would otherwise be two spellings of the same claim, and the honest one is absence. A producer that publishes no components has nothing to assert. It does not assert that there are none. ## OriginItem object ```json { "country": "ET", "region": "Guji", "producers": [{ "name": "Tesfaye Bekele", "role": "producer", "type": "person" }, { "name": "Nano Challa", "role": "cooperative" }], "altitude": { "min": 1900, "max": 2100, "unit": "meter" }, "harvest_time": "Oct–Dec 2025" } ``` | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `name` | string | no | The component coffee / lot **as the roaster labels it** (for example `"Alaka G1"`). Useful in blends whose components are named coffees. | | `country` | string (ISO 3166-1 alpha-2) | no¹ | Country of origin — factual, normalizable, localizable. For example `ET`, `CO`. | | `region` | string | no | Growing region **as the source states it**, at whatever granularity that is — usually within a country (`Guji`, `Huila`), sometimes broader than one (`East Africa`). | | `producers` | array of [Party](https://coffeejson.org/docs/spec/03-recipe.md#party-object) | no | The parties credited with producing this component, each with an optional `role`. See [Who produced it](#who-produced-it). | | `altitude` | [Altitude](#altitude-object) | no | Altitude above sea level as a unit-bearing value or range. | | `varietals` | array of string | no | This component's coffee varieties, as the roaster names them — the per-component counterpart of [`bean.varietals`](#varietals), for blends whose components differ. | | `process` | array of string (enum) | no | This component's post-harvest processes — same [vocabulary](https://coffeejson.org/docs/spec/06-vocabularies.md#process) as `bean.process`. For blends whose components are processed differently, and for a component that underwent more than one. | | `harvest_time` | string | no | Harvest period as stated by the roaster — a free string (for example `"2025"`, `"Oct–Dec 2024"`). | | `percentage` | number | no | This component's share of a **blend**, `0`–`100`. Meaningful only when the origin has several `items`; omit for a single origin. | ¹ All fields are optional, but an item **SHOULD** carry at least `country`. An origin item with no country conveys little and is hard to localize. The exception is a source that names no country itself. A roaster who writes only "East Africa" has named a real growing region that spans several countries. An inferred code would assert a precision they did not, and a dropped component would turn a two-component blend into a single origin. Record what the source states, usually `region`. Accept that it renders in one language. **Never invent a `country` to satisfy this SHOULD.** `region` carries the growing region **at whatever granularity the source states it**, and the variance runs in both directions. It is usually sub-national (`Guji`, `Huila`), sometimes supra-national (`East Africa`, `Central and South America`). Sometimes it is narrower than a country in a way that still names no country: an island (`Sumatra`) listed where the sibling components name states. All three are well-formed, and all three can appear without `country`. A consumer that groups by country will find items with no country for both reasons: the source was too broad, or too narrow. Both are faithful records, and neither is an error to repair. `country` uses [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1) codes (two uppercase letters). This is the one origin field that is fully factual and localizable. `ET` renders as "Ethiopia", "エチオピア", or "Etiópia" depending on the consumer's locale. See [Vocabularies § Country codes](https://coffeejson.org/docs/spec/06-vocabularies.md#country-codes). ### Who produced it A coffee's origin usually credits **more than one party**, and they are different kinds of thing. A roaster names a farmer *and* their farm, or a cooperative *and* the washing station that processed the lot. Sometimes a mill or an exporter is named as well. Sources that label their own fields make the distinction explicit. Japanese pages routinely print 生産者 (producer) beside 農園 (farm) as separate rows. So `producers` is an array of [Party](https://coffeejson.org/docs/spec/03-recipe.md#party-object) entries, each with an optional `role`. It is the same object a roaster or a recipe author is, so a consumer renders one with the code it already has: ```json "producers": [ { "name": "Edgardo Tinoco", "role": "producer", "type": "person" }, { "name": "Pino de Oro", "role": "farm" } ] ``` - **Order is the source's.** No entry is "primary". A consumer that shows one shows the first and **SHOULD** keep the rest reachable. - **`role` is omitted when the source does not label the party.** That is common, and honest. An entry that is only `{ "name": "Finca Las Brisas" }` says exactly what the page said, no more. - **`role` is an [open registry](https://coffeejson.org/docs/spec/06-vocabularies.md#producer-role)**, so a supply chain that names a part this list does not cover needs no spec revision. A consumer shows an unrecognized role beside the name rather than drop the party. ### Processes are a set `process` is a **list**, on the bean and on each origin item, because sources routinely state more than one and a single value cannot carry them: ```json "process": ["anaerobic", "honey"] ``` Two readings, one field: - **One coffee, several processes.** A bag that states "Double Anaerobic Honey" had an anaerobic fermentation *and* a honey drying. `honey` alone is true but incomplete. The coffee becomes indistinguishable from a plain honey in any search, which is exactly what the roaster was selling against. - **A blend, stated at bag level without assignment.** A roaster prints "Process / Washed, Natural" for a three-origin blend. That says the bag contains coffee of each, without saying which component is which. The bag-level list is that claim exactly. The items stay silent rather than invent an assignment. Both are true statements of the same shape, *these processes are present in this coffee*. Which reading applies is answered by the [origin](#origin-object), not by this field. A single element is the common case and needs no thought. **Order carries no meaning.** Publishers state the parts in whatever order they like, and it is rarely the order they happened in. One bag writes "Double Anaerobic Honey" (fermentation first). Another writes "ナチュラル、アナロビック" (drying first) for the same shape of coffee. A consumer **MUST NOT** read the first element as primary. When a blend's components differ, a washed Colombia beside a natural Ethiopia, the difference belongs on the **item**. Item-level `process` and `varietals` state each component's own, and `name` carries the component's label. A blend that names eight varieties across four components has them on the components. A single bean-level list would assert an eight-variety mixture the roaster never claimed. Bean-level `process` remains the bag-level claim about the coffee as a whole. When both are present and disagree, the item is the more specific claim. Components can also be stated at different **granularities**, because roasters describe them that way. Here one component is a country and its department. The other is a region the roaster names without naming a country: ```json "origin": { "type": "blend", "items": [ { "country": "CO", "region": "Huila", "process": ["washed"] }, { "region": "East Africa", "process": ["natural"] } ]} ``` Both items are well-formed. A consumer that renders origin treats `region` and `country` as independently optional. It does not assume `region` qualifies a country that is present. ## Altitude object Altitude uses the same unit-identifier principle as [Measurement](https://coffeejson.org/docs/spec/03-recipe.md#measurement-object). It permits ranges because coffee origins are commonly listed as elevation bands. ```json { "value": 1900, "unit": "meter" } ``` ```json { "min": 1700, "max": 1900, "unit": "meter" } ``` | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `value` | number | one of | A single elevation. | | `min` | number | one of | Lower bound for an elevation range. | | `max` | number | one of | Upper bound for an elevation range. | | `unit` | string | yes | Length unit identifier: `meter` or `foot`. | An Altitude object **MUST** contain `unit` and at least one of `value`, `min`, or `max`. Producers **SHOULD** emit `meter`. `foot` is allowed for sources that state elevation in feet. Consumers **MUST** convert any recognized altitude unit to their canonical store and localize display at the edges. --- # Tasting A **Tasting** is how one brewed cup turned out: the outcome of following a recipe, not the recipe itself. It is the third top-level entity of CoffeeJSON v1.0, carried as an element of the `tastings` array in the [document envelope](https://coffeejson.org/docs/spec/02-envelope.md) beside [Recipe](https://coffeejson.org/docs/spec/03-recipe.md) and [Bean](https://coffeejson.org/docs/spec/04-bean.md). It points back at what it evaluates: a [`recipe_ref`](#fields) at the brew, a [`bean_ref`](#fields) at the coffee. Both are optional and both resolve independently. See [Envelope § Association (a tasting's recipe and coffee)](https://coffeejson.org/docs/spec/02-envelope.md#association-a-tastings-recipe-and-coffee). Unlike `beans` and `recipes`, a `tastings` array does **not** on its own make a valid document. A tasting evaluates something, so a document that carries only tastings describes nothing a consumer can act on. ```json { "recipe_ref": "morning-v60", "rating": 4, "perceived": { "extraction": -0.2, "strength": 0.1 }, "descriptors": ["blackberry", "floral"], "note": "best one this week", "measured": { "tds": 1.38 } } ``` Every field is optional. A tasting can be as thin as a single refractometer reading or as rich as a full impression with a note in the drinker's own words. A tasting is **not a journal entry**. It carries no timestamp, no personal identity, and no inventory state. It says how a cup was, not who drank it or when. ## Fields | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `id` | string | no | Document-local name for this tasting, so something outside the `tastings` array can say which one it means. Unique within `tastings`, compared exactly. See [Tasting `id`](#tasting-id). | | `recipe_ref` | string | no | The [`id`](https://coffeejson.org/docs/spec/03-recipe.md#id) of a recipe in this document — the brew this cup came from. Exact, case-sensitive. An unresolved reference leaves the tasting unlinked; consumers **MUST NOT** fail. | | `bean_ref` | string | no | The [`id`](https://coffeejson.org/docs/spec/04-bean.md#id) of a bean in this document — the coffee that was brewed. Same matching and failure rules. | | `rating` | integer | no | 1–5, how much the drinker liked **this cup**. See [`rating`](#rating). | | `perceived` | object | no | How the cup was perceived, on the two dial-in axes. See [Perceived](#perceived). | | `descriptors` | array of string | no | What the drinker tasted, in the drinker's own words. Free strings, displayed verbatim — see [Flavor descriptors](https://coffeejson.org/docs/spec/06-vocabularies.md#flavor-descriptors) for the comparison rule. | | `note` | string | no | The drinker's own words. Producers that treat notes as private **SHOULD** omit the field rather than emit an empty string. | | `lang` | string (BCP-47) | no | Language of this tasting's human text (`note`, `descriptors`), as a well-formed BCP-47 tag. A hint only. See [Tasting `lang`](#tasting-lang). | | `measured` | object | no | What an instrument read. See [Measured](#measured). | ## Impression and measurement are kept apart The split down the middle of this object is by design. `rating`, `perceived` and `descriptors` are **attributed opinion**: one drinker, one cup, one occasion. `measured` is **measured fact**: an instrument reading anyone with the same instrument would reproduce. They are never mixed, and a consumer **MUST NOT** present one as the other. This is why a `rating` is permitted at all. CoffeeJSON does not carry quality scores for a coffee. `rating` is not "this coffee is a 4". It is "the person who wrote this file liked this cup" — the impression belongs to whoever produced the document, scoped to one brew, and never comparable across producers. [`generator`](https://coffeejson.org/docs/spec/02-envelope.md#generator) is optional and names software, not a drinker: a consumer **MAY** show it as a courtesy and **MUST NOT** read it to decide whose impression this is. ## Tasting `id` A document-local name for one tasting, exactly as a recipe's [`id`](https://coffeejson.org/docs/spec/03-recipe.md#id) is for one recipe. It is any non-empty string, unique within the document's `tastings` array, compared **exactly and case-sensitively**. It is never a global identifier, an account, or an inventory key. It exists for the same reason: **array position is not an identity.** Three tastings of one recipe are otherwise an unordered bag. Only their position tells them apart. This specification never declares position authoritative for `tastings`, as it does for [`steps`](https://coffeejson.org/docs/spec/03-recipe.md#step-object). A document regenerated with one cup added in the middle silently renames every tasting after it. - **Optional, and worth emitting whenever a document carries more than one tasting.** A single tasting needs no id. - **Producers SHOULD keep an id stable** across re-publication of the same cup. - Uniqueness is a semantic rule JSON Schema cannot express. A validator **SHOULD** show a duplicate as a warning. A consumer that resolves a reference to a duplicated id **SHOULD** treat it as unresolved. An `id` is **not a timestamp**. A tasting still carries no time of brewing and no drinker identity. Naming a cup is not dating it. ## Tasting `lang` The BCP-47 tag that hints what language this tasting's human text is in: its [`note`](#fields) and its `descriptors`, both the drinker's own words. It has the same form and the same status as a recipe's [`lang`](https://coffeejson.org/docs/spec/03-recipe.md#fields): a well-formed tag, hyphen-separated, and a **hint only**. A tasting has **no `localizations`** counterpart, and will not get one. A recipe's localizations exist because a publisher translates work meant for an audience. A tasting is one person's account of one cup, and nobody publishes translations of their own tasting note. A producer with the same cup described in two languages has two tastings, not one with an overlay. ## `rating` An integer **1–5**. The scale is declared here rather than left to the schema's bounds. A score whose system is unstated is a number a consumer can only misread. **Importing from another scale.** A producer whose own rating runs on a different system maps to the **nearest whole value in 1–5**. A 10-point score halves and rounds. A half-star scale rounds away from zero. A three-tier like/neutral/dislike takes 5, 3 and 1. Some scales **cannot** be mapped: an unbounded score, or a scale with no "worse" and "better". Such a producer **MUST** omit `rating` rather than emit a value that reads as a comparable star count. The field stays an integer. Widening it to accept halves later is additive and costs no existing document. Narrowing a number back to an integer would break every document that used a fraction. So the tighter shape is the one that keeps the choice open. ## Perceived Both axes run **-1 to 1** with `0` meaning "about right": | Axis | -1 | 0 | +1 | | --- | --- | --- | --- | | `extraction` | sour, acidic, under-extracted | balanced | bitter, harsh, over-extracted | | `strength` | weak, watery, thin | about right | strong, heavy, muddy | These are **impressions, not readings**. `perceived.extraction` is what the drinker tasted. It is not an extraction yield and **MUST NOT** be derived from one or rendered as a percentage. The member is named for its provenance, *perceived*, as against [`measured`](#measured), and not for its shape. So it can hold a third dimension without its name becoming false. One consequence is worth stating rather than designing away: the **-1 to 1 bipolar scale is a property of these two dimensions, not of the member**. `extraction` and `strength` are directions with a correct middle. A perceived *intensity* (body, sweetness) would be a magnitude with no such middle, so it would carry its own scale. A consumer **MUST NOT** assume every member of `perceived` runs -1 to 1. **Why not `axes`.** The two dimensions are still axes, and this document still calls them that. But a member named for them describes its shape, which is the half that will not survive. The day a perception arrives that is not a dial-in axis, it either goes somewhere else or makes the member's name a lie. The first gives "how it tasted" two homes. `perceived` says what the values are, which is what every consumer needs to know before rendering one. ## Measured | Field | Type | Req? | Notes | | --- | --- | --- | --- | | `tds` | number | no | Total dissolved solids in the beverage, as a **percentage by mass**, as read by a refractometer. Greater than 0. | | `yield` | [Measurement](https://coffeejson.org/docs/spec/03-recipe.md#measurement-object) (mass) | no | Beverage mass actually **weighed** out of this brew. A scale reading of this cup, not the recipe's target. See below. | Filter coffee usually lands near 1.2–1.6 and espresso near 8–12. The schema does not bound `tds` to those ranges. Concentrates and cold brew exceed them, and a schema that rejected a true reading would be worse than one that accepts a surprising one. **Two beverage masses, and which one wins.** A recipe's [`yield`](https://coffeejson.org/docs/spec/03-recipe.md#fields) is the mass the brew *aimed at*. `measured.yield` is the mass that *came out*, weighed. They are different facts, and the format carries both. A cup that landed at 258 g against a 262 g target is a 258 g cup, and only the tasting can say so. That matters most where a recipe has no `yield` at all, the normal case for immersion brewing stated by water. **Extraction yield is not a field.** It is derived. Carried as well, the same quantity would have two homes that can disagree: ``` extraction yield % = (beverage mass × TDS %) ÷ dose ``` A consumer that computes it takes the beverage mass from **`measured.yield` when the tasting carries one, and the recipe's [`yield`](https://coffeejson.org/docs/spec/03-recipe.md#fields) otherwise**: the measurement of this cup before the target it was brewed against. The dose is the recipe's [`coffee`](https://coffeejson.org/docs/spec/03-recipe.md#fields). A consumer that lacks any of the three **MUST NOT** guess. Beverage mass and dose come to one mass unit — grams — before they divide; an operand stated by volume, or in a unit the consumer does not recognize, yields **no** number; and a window is reduced only under [Recipe § Stated windows](https://coffeejson.org/docs/spec/03-recipe.md#stated-windows). ## Example ```json { "coffeejson": "1.0", "recipes": [ { "id": "morning-v60", "title": "Morning V60", "method": "pour_over", "coffee": { "value": 18, "unit": "gram" }, "water": { "value": 300, "unit": "gram" }, "yield": { "value": 262, "unit": "gram" } } ], "tastings": [ { "recipe_ref": "morning-v60", "rating": 4, "perceived": { "extraction": -0.2 }, "descriptors": ["blackberry", "floral"], "measured": { "tds": 1.38 } } ] } ``` That cup: liked it, tasted slightly under-extracted, measured 1.38 % TDS. With 262 g of beverage from 18 g of coffee that works out to about 20.1 % extraction yield, a number the document does not state and any consumer can compute. --- # Vocabularies & registries CoffeeJSON is [locale-neutral on the wire](https://coffeejson.org/docs/spec/01-overview.md). Everything enumerable travels as a stable machine id, and each consumer renders its own localized label. This document is the single home for every controlled vocabulary. Three kinds appear here: - **Closed enums** — small, stable value sets defined entirely by the specification (for example `roast_level`). New values arrive only with a spec revision. - **Open registries** — curated, extensible lists of ids with an explicit escape hatch (`gear`, `varietal`, `country`, addition `type`). Coverage grows over time and is never required to be complete, because an off-registry value always has a defined fallback. - **Free strings** — values carried verbatim (`setting`, `harvest_time`), or still exploratory and **reserved for a future registry** (`drying_method`, `certifications`). **The tiering rule.** A vocabulary is a *closed enum* when its value set is small, stable, and load-bearing for interop. That is where cross-implementation comparison or unit conversion depends on shared meaning, and a wrong guess is worse than no value. It is an *open registry* when the set is open-ended but canonical ids pay for themselves in localization and matching. It stays a *free string* when the value is inherently verbatim or the space is still exploratory. **Promotion is one-way.** A free string can later be backed by a registry: pure data plus SHOULD guidance, which changes no document's validity. A closed enum grows by minor revision. Each candidate value passes three gates: - **common** — real sources keep stating the concept and this vocabulary cannot express it. Both outcomes count: documents that land in the fallback, *and* documents that omit the field. The second happens when the fallback would say less than the source did while looking like a positive claim. A careful producer chooses the second, so a count of the first alone would make a well-behaved corpus look like it had no demand. - **converged** — the field has settled on one name for it. - **queryable** — consumers would filter by it. A value meaningful only inside one producer's own lineup fails here however often it appears. A shipped free string never becomes an enum, because that would invalidate existing documents. **Unknown-value handling — the dividing line.** Three behaviors appear below, and the split is principled. **Categorical** sets, where "something not listed" is itself a usable answer, map unknown values to **`other`** (`method`, step `kind`, `process`, `form`, filter `material`). **Ordered scales and claims**, where a wrong bucket would assert something false about the coffee, are **ignored**, each with its stated recovery. Those are: `roast_level` → prefer `roast_agtron`; grind `size` → prefer `setting`/`microns_approx`; party `type` → infer by role; `preferred_extraction` → ignore the value, keep the recipe. **Derivable switches**, where the document's own data answers the question, are **derived** (`origin.type` from the item count; `basis` from the quantities present). The mechanical consequence: an enum defines an `other` value exactly when mapping to it is safe. A vocabulary without `other` follows its stated ignore or derive rule. **Casing.** Enum values and free-string tokens are lowercase `snake_case` (`pour_over`, `raised_bed`). Registry slugs are `kebab-case` (`hario-v60`). Externally standardized codes follow their standard (ISO 3166-1 uppercase alpha-2; BCP-47 canonical casing). Some free strings are not tokens at all but **human text carried verbatim**: a grinder `setting`, a `harvest_time`, a flavor descriptor. Those have no casing rule to follow. They keep the source's own spelling, spacing and case, and a producer **MUST NOT** tokenize them. Where two such values need to be compared, the section that defines them says how. For **all** of them, the [forward-compatibility contract](https://coffeejson.org/docs/spec/01-overview.md) holds: an unrecognized value **MUST NOT** cause a consumer to fail. Each vocabulary below states its specific fallback. The published JSON Schema is the **producer gate for the current minor**. It rejects values a newer minor may define, and it is never an inbound import gate. See [Versioning § The published schema](https://coffeejson.org/docs/spec/07-versioning.md#the-published-schema). ## Index | Vocabulary | Kind | Used by | Fallback for unknown value | | --- | --- | --- | --- | | [Units](#units) | closed | `coffee`, `water`, `yield`, `to_water`, `water_temp`, `pressure`, `altitude` | Treat measurement as absent | | [`method`](#method) | closed | `recipe.method` | `other` | | [`basis`](#basis) | closed | `recipe.basis` | Derive from the quantities present | | [Step `kind`](#step-kind) | closed | `recipe.steps[].kind` | `other` | | [Grind `size`](#grind-size) | closed | `recipe.grind.size` | Ignore field (prefer `setting`/`microns_approx`) | | [`process`](#process) | closed | `bean.process`, `bean.origin.items[].process` | `other` | | [Filter material](#filter-material) | closed | `recipe.filter.material` | `other` | | [`roast_level`](#roast_level) | closed | `bean.roast_level` | Ignore field | | [`form`](#form) | closed | `bean.form` | `other` | | [`preferred_extraction`](#preferred_extraction) | closed | `bean.preferred_extraction` | Ignore field | | [`origin.type`](#origintype) | closed | `bean.origin.type` | Derive from item count | | [Party `type`](#party-type) | closed | `author.type` · `roaster.type` (any [Party](https://coffeejson.org/docs/spec/03-recipe.md#party-object)) | Ignore field; infer by role | | [Gear registry](#gear-registry) | open | `brewer`, `grind.grinder` (`id`) | `label` → `brand`/`model` | | [Addition `type`](#addition-type) | open | `recipe.additions[].type` | Handled generically | | [Producer `role`](#producer-role) | open | `producers[].role` (any [Party](https://coffeejson.org/docs/spec/03-recipe.md#party-object)) | Display beside the name | | [Varietal registry](#varietal-registry) | open | `bean.varietals[]` | Pass through verbatim | | [`drying_method`](#drying-method) | free² | `bean.drying_method` | Pass through verbatim | | [`certifications`](#certifications) | free² | `bean.certifications[]` | Pass through verbatim | | [Flavor descriptors](#flavor-descriptors) | free² | `tasting.descriptors[]` | Pass through verbatim | | [Country codes](#country-codes) | open¹ | `bean.origin.items[].country` | Pass through verbatim | ¹ Country codes are an external standard (ISO 3166-1), referenced rather than curated by CoffeeJSON. ² The **free-string** kind: no controlled vocabulary, and a consumer passes every value through unchanged. `drying_method` and `certifications` are exploratory, reserved for a future registry (the one-way promotion path above). Flavor descriptors are free **by design** and are not reserved for one. They are the drinker's own words, and a list would lose the words worth keeping. --- ## Closed enums ### Units Used by every [Measurement](https://coffeejson.org/docs/spec/03-recipe.md#measurement-object) and by [`bean.origin.items[].altitude`](https://coffeejson.org/docs/spec/04-bean.md#altitude-object). | Dimension | Unit identifier | Conversion to recommended store | | --- | --- | --- | | Mass | `gram` | already grams | | Mass | `ounce` | `1 ounce = 28.349523125 gram` | | Volume | `milliliter` | no defined conversion to mass — see below | | Temperature | `celsius` | already Celsius | | Temperature | `fahrenheit` | `celsius = (fahrenheit − 32) × 5⁄9` | | Length | `meter` | already meters | | Length | `foot` | `1 foot = 0.3048 meter` | | Pressure | `bar` | already bar (v1.0's only pressure unit) | **Volume is water-only, and unconvertible by design.** `milliliter` is accepted by [`recipe.water`](https://coffeejson.org/docs/spec/03-recipe.md#water-quantity) and [`step.to_water`](https://coffeejson.org/docs/spec/03-recipe.md#step-object) and by nothing else. Publishers state brew water either way. A Japanese guide that prints `お湯 92℃ 225cc` beside a dose in grams is ordinary. A dose, a beverage yield and an addition's amount are always masses. There is **no conversion factor**, because water's density varies with temperature. 225 mL is 225 g only near 4 °C, and at a stated 92 °C it is ≈216.8 g. A consumer that needs the other kind applies its own model. It **MUST NOT** present the result as the author's figure, the same rule as [Scaling](https://coffeejson.org/docs/spec/03-recipe.md#scaling). **`ounce` straddles that same line, because the name sits on both sides.** `ounce` is the avoirdupois **mass** ounce (28.349523125 g), never a fluid ounce. A source that states brew water in *fluid* ounces states a volume. Emit `milliliter` (10 fl oz ≈ 296 mL). A conversion from fluid ounces to `gram` crosses exactly the boundary the paragraph above declines to bridge. The naive figure holds only near 4 °C, and a US guide's `10 fl oz` is ≈285 g at brew temperature. These identifiers are wire values, not display strings. Producers **MUST** emit the canonical identifiers above and **MUST NOT** emit symbols or localized labels such as `g`, `oz`, `C`, `°C`, `grams`, or `グラム`. The identifiers are CoffeeJSON's own, chosen to read as words; they are not UCUM, QUDT, or UN/CEFACT codes, and the format defines no mapping to any of them. Consumers **MUST** convert any unit they recognize and **MUST** treat a measurement with an unrecognized unit as absent (never guess). ### `method` The brewing technique. Used by `recipe.method`. Unknown value → `other`. | Value | Meaning | | --- | --- | | `pour_over` | Gravity percolation with a manual pour (V60, Kalita, Origami…). | | `immersion` | Steep then separate (full-immersion drippers, steep-and-release). | | `aeropress` | AeroPress, any orientation. | | `french_press` | Full-immersion plunger pot. | | `moka` | Stovetop moka pot. | | `cold_brew` | Long ambient / cold extraction. | | `siphon` | Vacuum / siphon brewer. | | `cezve` | Cezve / ibrik / Turkish. | | `drip` | Batch / automatic filter machine. | | `capsule` | Pod / capsule system. | | `espresso` | Pressurized extraction — dose in, beverage **yield** out (typically paired with `basis: "yield"`, the structural switch). | | `other` | Anything not listed, or unknown. | New methods can be added in a minor version. That is why an unrecognized value maps to `other` rather than fails. `method` is descriptive only and does not itself change field requirements. The recipe's [`basis`](#basis) is the structural switch. A yield-basis recipe (`basis: "yield"`, usually paired with `method: "espresso"`) REQUIRES `yield` and **MUST NOT** carry `water` or `ratio`. Those fields (total brew water; water ÷ coffee) would misdescribe a shot. See [Recipe § Espresso (dose : yield)](https://coffeejson.org/docs/spec/03-recipe.md#espresso-dose--yield). ### `basis` Used by `recipe.basis`, **the structural switch** for which brew quantity a recipe states. Default `water` when absent. Unknown → **derive the effective basis from the quantities present**. | Value | Meaning | | --- | --- | | `water` | Stated by total brew water (the default): requires `water` **or** `ratio`, which each fix the other given the always-required `coffee`; `yield` MAY additionally state the beverage out. | | `yield` | Stated by beverage mass out (espresso's basis): requires `yield`; **MUST NOT** carry `water` or `ratio`. | Because `basis` decides which field is REQUIRED, it cannot grow the way other vocabularies do. A new value would change which required quantity exists, so it is **breaking in effect** and waits for a major version. See [Versioning § The required-quantity trap](https://coffeejson.org/docs/spec/07-versioning.md#versioning). A consumer can still meet an unknown value: a newer-major document, or malformed input such as a hand-authored `basis: "espresso"`. It derives the effective basis from the data, **in this order**, because a water-basis recipe MAY state a `yield` too and a bare "which is present" test would read one as a shot: `water` **or** `ratio` present → water-basis. Otherwise `yield` present → yield-basis. Otherwise the recipe states no brew quantity, and its title, steps, and notes still render. This is the same document-data-answers-it rule as `origin.type` and the [step data-guard](https://coffeejson.org/docs/spec/03-recipe.md#the-data-guard-rule). ### Step `kind` Used by `recipe.steps[].kind`. **Default `pour`** when absent. Unknown → `other`. `pour` and `bloom` are the **pour-type** kinds. See [Recipe § The data-guard rule](https://coffeejson.org/docs/spec/03-recipe.md#the-data-guard-rule) for how a consumer decides whether *any* step, including a future pour-type kind, is water-bearing. | Value | Timed? | Meaning | | --- | --- | --- | | `pour` | usually | Add water. Carries `to_water`; usually carries `at_s`. | | `bloom` | usually | Initial pre-wet pour — saturates the grounds before the main schedule. Carries `to_water` + `at_s` like `pour`. See [Recipe § Bloom](https://coffeejson.org/docs/spec/03-recipe.md#bloom). | | `prep` | no | Preparation (rinse filter, preheat). | | `wait` | usually | An interval with no action — a steep, a rest between pours, a cold-brew hold. Carries `at_s` and/or `action_duration_s`; moves no water. | | `stir` | optional | Agitate the slurry. | | `flip` | no | Invert the brewer (for example inverted AeroPress). | | `valve_open` | no | Open the brewer's valve — a Hario Switch released to drain. | | `valve_close` | no | Close the brewer's valve — a Switch shut to steep as an immersion. | | `press` | optional | Apply pressure / plunge. | | `drawdown` | optional | The bed draining after the last pour, when the author calls it out as its own step. | | `distribute` | no | Distribute / settle the grounds in the basket (WDT). | | `tamp` | no | Compact the puck. | | `pull` | optional | Run the espresso shot. Instructions only — the shot's numbers live on the recipe. | | `other` | optional | Any step kind not listed, or unknown. | A consumer that does not model a kind (a pour-over app that meets `tamp`, say) still preserves the step and shows it read-only (see [Recipe § Step](https://coffeejson.org/docs/spec/03-recipe.md)). `valve_open` and `valve_close` are brewer-state changes in the same family as `flip`. They move no water. They exist because a valved brewer is used two ways in one recipe, shut to steep and opened to drain. No other kind can express that. A recipe that never moves its valve does not need them. Not a value here: **`swirl`**. Agitating the slurry is `stir`, whichever motion the author describes. A second value for the same act would leave two producers describing one step differently. ### Grind `size` Used by `recipe.grind.size`: qualitative coarseness on a standard perceptual scale (ordered). It sits beside the grinder-specific `setting` and the approximate `microns_approx` (see [Grind object](https://coffeejson.org/docs/spec/03-recipe.md#grind-object)). Unknown → **ignore the field**, and prefer `setting` / `microns_approx` when present. `extra_fine` · `fine` · `medium_fine` · `medium` · `medium_coarse` · `coarse` · `extra_coarse` An ordered scale, finest to coarsest. `extra_fine` suits espresso, `extra_coarse` suits cold brew / French press, and pour-over sits in the middle grades. No cross-grinder conversion is implied. `size` is a shared vocabulary for perceptual coarseness, not a substitute for a grinder's own dial. ### `process` Post-harvest processing. Used by `bean.process` and, per blend component, by `bean.origin.items[].process`. The components of a blend are often processed differently, for example a washed Colombia beside a natural Ethiopia. Unknown → `other`. **Both fields are list-valued**, because a coffee often has more than one process to state and one value cannot say so. See [Bean § Processes are a set](https://coffeejson.org/docs/spec/04-bean.md#processes-are-a-set). | Value | Meaning | | --- | --- | | `washed` | Fully washed / wet process. | | `natural` | Dry / natural process. | | `pulped_natural` | Pulped natural. | | `honey` | Honey process. | | `anaerobic` | Anaerobic fermentation. | | `carbonic_maceration` | Carbonic maceration — whole cherries ferment in a sealed vessel under carbon dioxide. Naming it is not also a claim of `anaerobic`. A coffee stated as both carries both. | | `wet_hulled` | Wet-hulled (*giling basah*) — the bean is hulled at high moisture, the standard method across Sumatra. | | `other` | Any process not listed, or unknown. | A roaster's own process name stays in the bean's `description` or, for a blend component, often in the origin item's `name`. `process` carries the **comparable categories**. The distinction is between a name this vocabulary does not cover and a coffee that has several values it does. "Koji Natural" names a fermentation this list has no id for, so it is `["natural"]` plus the roaster's words in prose. "Double Anaerobic Honey" names two processes that are both here, so it is `["anaerobic", "honey"]` and nothing is lost. New values arrive by minor revision through the promotion gates stated in the introduction. ### Filter material Used by `recipe.filter.material`: what the brew filter is made of. Unknown → `other`. | Value | Meaning | | --- | --- | | `paper` | A paper filter: retains oils and fines, giving a cleaner cup. | | `metal` | A metal mesh or perforated screen — a French press plunger, a reusable cone. | | `cloth` | A cloth / flannel filter. | | `other` | Any filter material not listed, or unknown. | A small closed set, because the material is the part that is portable and affects taste. A consumer can filter a corpus by it, which the specific product name would not allow. The product itself goes in `filter.label`, free text, because it is usually implied by the brewer. **Not** a value here: "no filter". Unfiltered brewing (cezve, a cupping bowl) is real, but no source in the corpus states it yet. A value added before then would invite producers to assert absence where they mean silence. ### `roast_level` Used by `bean.roast_level`. An **ordered** scale. Unknown → ignore the field (prefer `roast_agtron` if present). `light` · `light_medium` · `medium` · `medium_dark` · `dark` · `extra_dark` Six values, matching the scale specialty coffee is sold and filtered with at retail (*Light / Light-Medium / Medium / Medium-Dark / Dark / Extra-Dark*). `extra_dark` covers French / Italian-style roasts. A roaster's own scale name stays in the bean's `description`. This enum carries the comparable category. ### `form` Used by `bean.form`. Unknown → `other`. | Value | Meaning | | --- | --- | | `bean` | Whole bean. | | `ground` | Pre-ground. | | `pod` | Sealed capsule / pod system (for example Nespresso-style). | | `drip_bag` | Single-serve drip bag (pre-portioned, hangs on the cup). | | `instant` | Soluble / instant coffee. | | `other` | Any form not listed, or unknown. | ### `preferred_extraction` Used by `bean.preferred_extraction`: the extraction style the roaster developed the roast for, as printed on the bag ("Preferred Extraction: Espresso"). A declared claim (tier 2), never a restriction. Any coffee can be brewed any way. Unknown → ignore the field. | Value | Meaning | | --- | --- | | `espresso` | Developed for espresso. | | `filter` | Developed for filter / gravity brewing. | | `omni` | Developed to work across both. | ### `origin.type` Used by `bean.origin.type`. Unknown / absent → derive from `items.length` (one item → `single`, more → `blend`). `single` · `blend` ### Party `type` Used by the [Party object](https://coffeejson.org/docs/spec/03-recipe.md#party-object)'s `type`: whether the credited party is a `person` or an `organization`, when the source makes it clear. Unknown or absent → **ignore the field and infer from the crediting field**. An `author` reads as a person. A `roaster` reads as an organization. A party whose [`role`](#producer-role) is `farm`, `cooperative`, `washing_station` or `mill` reads as an organization. Any other role, or none, leaves it unstated. The distinction matters chiefly to structured-data exporters (schema.org Person vs Organization). --- ## Open registries Open registries are curated id/alias/label data maintained beside this specification as plain JSON in the repository's `registries/` directory. They are served from the canonical host so any consumer can sync them: [`gear.json`](https://coffeejson.org/registries/gear.json) · [`varietals.json`](https://coffeejson.org/registries/varietals.json) · [`addition-types.json`](https://coffeejson.org/registries/addition-types.json) · [`producer-roles.json`](https://coffeejson.org/registries/producer-roles.json). The sections below state each registry's rules, with illustrative seeds. **The JSON files are the data.** If CoffeeJSON becomes a shared standard, they are the natural first thing to extract into a neutral, contribution-friendly repository, so that every adopter references the same slugs (see [Versioning § Registry governance](https://coffeejson.org/docs/spec/07-versioning.md#registry-governance)). ### Gear registry The set of known slugs for the [Gear](https://coffeejson.org/docs/spec/03-recipe.md#gear-object) `id` field (brewers, grinders, and baskets). Kebab-case slugs. **Non-exhaustive by design**: `id: "custom"` plus a `label` always works, so missing coverage never blocks a share. The canonical list is [`registries/gear.json`](https://coffeejson.org/registries/gear.json). Each entry carries a neutral `label`, its `roles`, a `category` where it brews, and `brand`/`model` where unambiguous. `"custom"` is the reserved escape hatch and not an entry. **`roles` says where an entry attaches; `category` says how it brews.** They are different questions and a single member answered neither cleanly. A `brewer` attaches at [`recipe.brewer`](https://coffeejson.org/docs/spec/03-recipe.md#fields), a `grinder` at [`grind.grinder`](https://coffeejson.org/docs/spec/03-recipe.md#grind-object), and a `basket` at [`recipe.basket`](https://coffeejson.org/docs/spec/03-recipe.md#fields). An **all-in-one carries more than one** — `breville-barista-express` and `xbloom-studio` are `["brewer", "grinder"]`, because one machine fills both slots in the same recipe. A consumer listing every brewer tests `roles` for `brewer` rather than enumerating brew families. Only a brewer has a `category`: `dripper` · `pour-over-machine` · `drip` · `immersion` · `stovetop` · `espresso-machine` · `capsule`. It overlaps [`method`](#method) without mirroring it — `method` is the technique and `category` is the kind of device, so `aeropress`, `french_press`, `siphon`, `moka` and `cezve` are methods performed *with* an `immersion` or `stovetop` device. The three devices that are easily confused: **`dripper`** is the vessel you pour into by hand (`hario-v60`), **`pour-over-machine`** is the motor that performs the pour for you (`xbloom-studio`), and **`drip`** is the batch filter running one shower head over a flat bed. A grinder has no `category`, because grinding is not a way of brewing. `drip` and `capsule` are defined and currently carry no entry: the registry is non-exhaustive, and a value exists here so the first such product has somewhere to land rather than forcing a vocabulary change with it. **An entry names a product at the granularity a source names it** — the family (`hario-v60`, `kalita-wave`, `vst-precision`), never its sizes, materials or generations. Those go in the Gear object's own [`variant`](https://coffeejson.org/docs/spec/03-recipe.md#gear-object), as the maker prints them: ```json { "id": "hario-v60", "variant": "02" } { "id": "kalita-wave", "variant": "185" } { "id": "kono-meimon", "variant": "MDN-41" } { "id": "vst-precision", "variant": "18 g" } { "id": "varia-vs3", "variant": "Gen 2" } ``` **`variant` is free text and is never coerced to an enum.** The varying axis is different for every family — `01`/`02`/`03`, `155`/`185`, `S`/`M`/`L`, `XL`/`Go`, ceramic/plastic/glass, `Gen 2` — so any closed list is wrong by the next family it meets. This is the same call [`grind.setting`](https://coffeejson.org/docs/spec/03-recipe.md#grind-object) already makes for the same reason. A few entries also list `aliases` — true synonyms of the same product, such as a vendor's own name for an OEM design (`turin-df64` for `df64`) or a regional brand name (`sage-bambino` for `breville-bambino`). **An alias is never a variant designation.** `vst-18g` is not an alias of `vst-precision`: resolving it would silently discard the dose, which is the one thing it says. A retired per-variant slug is not aliased to its family for the same reason — it is simply unrecognized, and an unrecognized id falls back correctly. A producer **SHOULD** emit the canonical `id`; a consumer that resolves aliases matches more sources, and one that does not still falls back correctly. Seed entries (illustrative, not the complete list): | Category | Example slugs | | --- | --- | | Drippers | `hario-v60` · `chemex` · `kalita-wave` · `origami` · `orea` · `april` · `clever-dripper` | | Pour-over machines | `xbloom-studio` | | Immersion | `aeropress` · `french-press` · `siphon` | | Stovetop | `moka-pot` · `cezve` | | Espresso machines | `breville-barista-pro` · `profitec-pro-600` · `rocket-appartamento` | | Baskets | `vst-precision` · `ims-precision` · `pullman-876` | | Grinders | `comandante-c40` · `1zpresso-jx` · `fellow-ode` · `baratza-encore` · `df64` | **Matching rule.** A consumer matches on `id`. For a known `id` it **SHOULD** substitute its own localized label, and **SHOULD** render `variant` beside it — `variant` is the one thing the registry cannot supply, so a consumer that drops it loses what the document knew. For `id: "custom"` or an unknown `id` it falls back to `label`, then to `brand` / `model`. It **MUST NOT** fail on an unrecognized `id`. **With a known `id`, a producer SHOULD omit `brand` and `model`** — the registry is authoritative for both, and a document that repeats them only drifts from it. `label` keeps its own job: what the source itself called the thing, which is worth carrying when the source wrote it in its own language (`"ドリッパー01(V60)"`). A consumer **MAY** show that as provenance beside its own label. Adding a slug is a data change, not a spec change. It does not bump the format version. ### Addition `type` **An open registry, not a closed enum.** Unlike `kind`, `process`, or `roast_level`, `recipe.additions[].type` has no fixed value set in the schema. Any non-empty string is valid, so there is no "unrecognized value" for the schema to reject. There is only a value a given consumer does or does not have special behavior for. Recommended values, for interoperability: `ice` · `milk` · `sugar` · `syrup` · `water` · `cream` The canonical list is [`registries/addition-types.json`](https://coffeejson.org/registries/addition-types.json). `ice` is the one value with a defined effect beyond its plain meaning. Its presence marks the whole recipe **iced** (see [Recipe § Additions](https://coffeejson.org/docs/spec/03-recipe.md#additions)). The rest are recommended so that two producers that describe the same addition use the same word. A producer **SHOULD** prefer them when they apply. A consumer **MUST** accept and show any other string generically (a flavored syrup's brand name, a bypass-water technique with its own house term) rather than reject it. ### Producer `role` **An open registry, not a closed enum.** `role` says what part a credited party played, and the supply chain names more parts than any fixed list would hold. Any non-empty string is valid. The recommended values name the parts an origin's producers play, and are what the registry is for: `producer` · `farm` · `cooperative` · `washing_station` · `mill` · `exporter` The canonical list is [`registries/producer-roles.json`](https://coffeejson.org/registries/producer-roles.json). An origin routinely credits several parties of different kinds: a named farmer and their farm, or a cooperative and the washing station that processed the lot. That is why [`producers`](https://coffeejson.org/docs/spec/04-bean.md#who-produced-it) is an array and why each entry carries its own role. `role` is an ordinary member of the [Party](https://coffeejson.org/docs/spec/03-recipe.md#party-object) shape, so any credit may carry one — a recipe `author` credited as the barista who developed it, a `roaster` stating its own part — and outside `producers` the source's own word is what a producer emits. A producer **SHOULD** prefer the recommended values when they apply. It **SHOULD** omit `role` entirely when the source names a party without labeling its part, rather than guess. A consumer **MUST** show an unrecognized role beside the name rather than drop the party, and **MUST NOT** treat a role-less entry as less real than a roled one. `role` is about the part played, not the legal kind of party. That is [`type`](#party-type), and the two are independent axes. Where `type` is absent, a role of `farm`, `cooperative`, `washing_station` or `mill` implies an organization, on whichever credit carries it. ### Varietal registry Canonical varietal names for `bean.varietals[]`, plus an alias map from common synonyms and breeding codes to a canonical name. The canonical data is [`registries/varietals.json`](https://coffeejson.org/registries/varietals.json). **Each entry says what kind of name it is.** A varietal field collects several different botanical claims — one bred selection, a whole breeding family, an undifferentiated local population, a species — and a flat list of names cannot tell them apart. `Heirloom` covers thousands of Ethiopian genotypes and `Pacamara` is one 1958 cross; a consumer grouping or filtering varietals needs to know which it is holding. | `kind` | What the name denotes | Registry examples | | --- | --- | --- | | `cultivar` | one named selection | `Bourbon` · `Gesha` · `SL28` · `Castillo` | | `group` | a breeding family covering many selections | `Catimor` · `Sarchimor` | | `landrace` | an undifferentiated local population | `Heirloom` · `Kurume` · `Wolisho` | | `species` | a species named as the varietal | `Liberica` · `Robusta` · `Eugenioides` | | `botanical_variety` | a named variety within a species | `Excelsa` · `Nganda` | | `interspecific_hybrid` | a cross between two species | `Timor Hybrid` | | `f1_hybrid` | a first-generation controlled cross | `H1` · `Starmaya` · `Milenio` | `species` carries the botanical epithet the name is sold as — `arabica` · `canephora` · `liberica` · `eugenioides` · `stenophylla` · `racemosa` · `charrieriana` · `congensis`, and `a-x-b` for a true cross. **An arabica cultivar carrying Timor Hybrid ancestry is `arabica`**, because that is what it is botanically and commercially: `Catimor`, `Castillo`, `Colombia`, `Lempira`, `Marsellesa`, `Parainema`, `Batian` and `Ruiru 11` are all `arabica`, and only `Timor Hybrid` itself is the cross. **Both members are optional.** A name whose parentage is genuinely disputed omits the key rather than guessing, because a forced guess is worse data than a stated gap. Every row here carries both where they are settled. Both members are **annotations on the match, never a constraint on a document**. `bean.varietals[]` stays an array of strings, a producer emits what the roaster claimed, and a consumer that ignores `kind` and `species` entirely behaves exactly as before. The `kind` set is open: an unrecognized value is ignored, not an error. Canonical examples: `Bourbon` · `Caturra` · `Catuai` · `Typica` · `Gesha` · `SL28` · `SL34` · `Heirloom` · `Pacamara` · `Pacas` · `Mundo Novo`. Alias examples (alias → canonical): | Alias / code | Canonical | | --- | --- | | `Geisha` | `Gesha` | | `ゲイシャ` | `Gesha` | | `ブルボン` | `Bourbon` | | `BM139` | `Batian` | | `CAT129`, `Nyika` | `CAT129` (a.k.a. Nyika) | | `H1`, `Centroamericano` | `H1` | A producer **SHOULD** emit a canonical name when it knows one. A consumer that does not recognize a value **MUST** pass it through unchanged. Varietal coverage is open-ended, and a dropped unknown varietal loses real information. **Aliases carry scripts, not just spellings.** A roaster that publishes in Japanese writes `ゲイシャ`, `ブルボン`, `ティピカ` and gives no Latin original. A document that records what the page said is then unreachable by a consumer that filters for Geisha, Bourbon or Typica. The alias map closes that gap without anyone translating. **The document keeps the source's own value**, and matching happens against the registry. That distinction matters. A transliteration at transcription time would be authoring, and it is lossy in one direction (katakana cannot round-trip a producer's accents). So it is the consumer's lookup that normalizes, never the producer's pen. ### Flavor descriptors What a drinker tasted, for [`tasting.descriptors[]`](https://coffeejson.org/docs/spec/05-tasting.md#fields): `blackberry`, `floral`, `dark chocolate`. **Free strings, and uncurated by design.** CoffeeJSON ships no descriptor list and reserves none. A producer emits the words its users chose. A consumer **MUST** pass every descriptor through and show it verbatim rather than drop, tokenize or rewrite it. "Smells like my grandmother's kitchen" lost to a controlled vocabulary would lose the only part of a tasting the drinker cared about. A descriptor is **display text, not an id**. It carries spaces and the source's own casing, `dark chocolate` rather than `dark_chocolate`, and the `snake_case` token rule above does not reach it. **Comparing two descriptors.** A consumer that needs to know whether two descriptors are the same (to match a cup against another, or filter a library) compares them by **folding case and trimming leading and trailing whitespace**, and nothing further. `Dark Chocolate`, `dark chocolate` and ` dark chocolate ` are one descriptor. `dark-chocolate` is a different one. No stemming, no synonym table, no punctuation stripping. Each of those would be a consumer inventing a vocabulary the format declined to ship, and two consumers that invent different ones is exactly the failure this rule exists to prevent. Whatever the comparison decides, what is **stored and displayed** is still the string the document carried. Two things this vocabulary is not. It is **not** a mapping onto a published sensory lexicon. Aligning descriptors to an industry flavor wheel is a [reserved extension](https://coffeejson.org/docs/spec/07-versioning.md#reserved-extensions), not something a producer attempts at authoring time. And it is **not** [`bean.roaster_notes`](https://coffeejson.org/docs/spec/04-bean.md#roaster-notes). Those are the roaster's claim about a coffee. These are one drinker's impression of one cup, and a consumer **MUST NOT** merge them. ### Drying method `bean.drying_method` records how the coffee was dried (for example `raised_bed`, `patio`, `covered_patio`, `mechanical`), distinct from `process`. **No controlled vocabulary in v1.0.** It is a free string. A consumer renders it (for example by Title-casing a slug) and **MUST** pass an unrecognized value through unchanged. A future registry may curate common methods. Adding one is a data change, not a version bump. ### Certifications `bean.certifications[]` carries roaster-declared certification / production-claim strings (for example `organic`, `fair_trade`, `rainforest_alliance`, `kosher`, `biodynamic`). **No controlled vocabulary in v1.0.** They are free strings, a *stated* claim, never an independent audit. A consumer **MUST** pass unknown values through unchanged. A future registry may normalize common claims. ### Country codes `bean.origin.items[].country` uses [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1): two uppercase letters, for example `ET` (Ethiopia), `CO` (Colombia), `KE` (Kenya), `BR` (Brazil), `GT` (Guatemala), `PA` (Panama), `ID` (Indonesia). This is an external standard, not a CoffeeJSON-curated list. The codes are stable and every platform ships a localization for them, so a consumer renders the country name in its own locale from the code. A value that is not a valid ISO code **SHOULD** be passed through verbatim rather than dropped. Continent / region-group facets (*Africa*, *Central America*, …) are **derived** by consumers from the code. CoffeeJSON carries no continent field. --- # Versioning & conformance ## What you can rely on today The format and its SDKs run on **different clocks**, and the rules in this chapter govern only the first. A document's `coffeejson` version says nothing about a package's API version. | Surface | Status | What that means for you | |---|---|---| | The wire format (`coffeejson: "1.0"`) | **Early, one implementation** | The shape is settled and validated against a fixture corpus. Fields may still be added; the meaning of a defined field will not quietly change. | | The JSON Schema at its `$id` | **Stable address** | `https://coffeejson.org/schema/1.0` keeps resolving, re-published in place as 1.x grows — see [The published schema](#the-published-schema). | | `@coffeejson/core`, `@coffeejson/react`, `coffeejson-swift` | **1.0.0, semver** | Reference implementations that track the format while it can still change in place. A breaking API change is a major bump; a format change in place ships as a package release. The Swift package models the subset its consumers use rather than the whole format. | While CoffeeJSON has one implementation the format may still change shape in place. See [Evolving 1.0 in place](#evolving-10-in-place) for what that latitude covers and when it ends. If you build during that window, pin a copy of the schema. The [changelog](https://github.com/coffeejson-org/coffeejson/blob/main/CHANGELOG.md) and the dated schema snapshots are the signal. ## Versioning The `coffeejson` envelope field carries the [semantic version](https://semver.org) of the specification a document conforms to. - **Within a major version**, every change is **additive and optional**. New optional fields and new enum values can be introduced. Consumers ignore what they do not recognize (the [forward-compatibility contract](https://coffeejson.org/docs/spec/01-overview.md)). A document valid against `1.0` remains valid against `1.x`. - **A breaking change bumps the major version.** Removing a field, changing a field's type, repurposing a value, or making an optional field required are all breaking and would produce `2.0`. - The **minor** version increments when additive features are introduced. There is no patch component on the wire. Editorial fixes to this document that do not change the data model do not change the version a document declares. **The required-unit trap.** A new [unit](https://coffeejson.org/docs/spec/06-vocabularies.md#units) is only additive for **optional** measurements. A consumer treats an unrecognized unit as absent ([Conformance](#conformant-consumer)). That is harmless on an optional field like `water_temp`, but on a **required** measurement it deletes the field. If a 1.1 added `kilogram` and a producer emitted it for `coffee`, every 1.0 consumer would read that recipe as having *no dose at all*, which is unusable despite "additive" versioning. So a minor can add units usable on optional measurements. A unit intended for a required measurement (`coffee`, `water`, `yield`) is a breaking change in effect and waits for a major. The alternative, a producer that emits the 1.0 unit beside its preferred one, is not modeled. Emit required measurements in the units 1.0 defines. `milliliter` on `water` is the one such unit 1.0 itself defines. **The required-quantity trap.** [`basis`](https://coffeejson.org/docs/spec/06-vocabularies.md#basis) generalizes the same hazard to structure. A new `basis` value changes *which* required quantity a recipe states, so an older consumer reads such a recipe as missing its brew quantity entirely. A new `basis` value is breaking in effect and waits for a major. A consumer that meets an unknown one anyway derives the effective basis from the quantities present ([Vocabularies § `basis`](https://coffeejson.org/docs/spec/06-vocabularies.md#basis)). ### Evolving 1.0 in place CoffeeJSON 1.0 has a single known implementer and no second consumer to keep compatible. While that holds, the format may evolve directly, relocating or removing a field included, without a version bump or a back-compatibility shim. The version stays `"1.0"`. The schema `$id` stays `https://coffeejson.org/schema/1.0`. The [changelog](https://github.com/coffeejson-org/coffeejson/blob/main/CHANGELOG.md) records each change. This latitude ends at first outside adoption, after which the rules above apply unconditionally. **Documents minted by that implementer exist in the wild.** A change in place costs a migration of whatever that producer serves, and the cost grows with every document minted. It is still the right call while a better design is available. ### The published schema The [JSON Schema](https://coffeejson.org/schema/1.0) published at its `$id` is a **producer gate for the current minor**. It validates what a producer can emit *today*, which includes rejecting vocabulary values a newer minor may later define. Each minor **re-publishes the schema at the same `$id`** with the union of that major line's values, so the producer-visible gap is bounded by release cadence. It is **never an inbound import gate**. A consumer MUST NOT reject a document because it fails schema validation on vocabulary values introduced by a newer minor. The [fallback rules](https://coffeejson.org/docs/spec/06-vocabularies.md) govern import. An **authoring variant** ([`coffeejson-1.0.authoring.schema.json`](https://coffeejson.org/schema/authoring/1.0)) is generated from this schema and published at its own `$id` `https://coffeejson.org/schema/authoring/1.0`. It closes every object except the reserved [`ext`](#reserved-extensions) member, which a [localization](https://coffeejson.org/docs/spec/03-recipe.md#localizations) refuses too, requires optional arrays to be non-empty, and requires [`bean_ref`](https://coffeejson.org/docs/spec/03-recipe.md#bean_ref) on every recipe once a document carries more than one bean. It catches three producer mistakes: a typo'd field name, which the open runtime schema accepts silently; an empty emission that a producer omits instead; and a recipe left unlinked when a second coffee joined the document and [co-location](https://coffeejson.org/docs/spec/02-envelope.md#association-co-location) stopped associating anything. Each of those is a document the runtime schema accepts, and an unreferenced recipe is a state the envelope defines. It is a strict producer lint, never a conformance or import gate. ### Reserved extensions Some growth areas are **named but not defined** in v1.0. Naming them means each can be added within the 1.x line without a breaking change. A producer **MUST NOT** emit reserved fields as if they were defined in v1.0. A consumer that meets an unknown member ignores it, per the [forward-compatibility contract](https://coffeejson.org/docs/spec/01-overview.md#the-forward-compatibility-contract-summary). 1. **A professional cup-scoring module.** The consumer half of this is in v1.0. The [Tasting](https://coffeejson.org/docs/spec/05-tasting.md#fields) entity carries a drinker's rating, the perceived extraction/strength axes, flavor descriptors and a measured `tds`. That completes the small composable graph (*recipe ↔ bean ↔ tasting*). A tasting is distinct from [`bean.roaster_notes`](https://coffeejson.org/docs/spec/04-bean.md#roaster-notes). Notes are the roaster's attributed claim. A tasting is one drinker's evaluation of one cup. What stays reserved is the **professional** layer: a panel that scores coded samples on a named system. That system is the 2004 cupping form, the 2024 Coffee Value Assessment, or a house scale. It is not a field the Tasting object is missing but a different artifact, with a different author, cardinality and audience. It also cannot be carried honestly as a bare number. The same attribute scores 6–10 in quarter points on one system and 1–9 on another. A score without a declared system is a number a consumer can only misread. A scoring module names its system, its attributes and its scale, or it does not ship. 2. **Pressure / flow profiling.** The espresso model itself is whole in v1.0: method id, `basis`, `yield`, `pressure`, `preinfusion_s`, `basket`, and the `distribute` / `tamp` / `pull` step kinds ([Recipe § Espresso](https://coffeejson.org/docs/spec/03-recipe.md#espresso-dose--yield)). What stays reserved is the *dynamic* layer: a named, multi-phase, machine-executable pressure or flow profile (Decent-style shot files, a roaster's named preset). v1.0 carries the nominal numbers and free-text phase instructions. A structured profile object waits for real consumer pull. 3. **Descriptor normalization.** Aligning `roaster_notes` (and a future `tasting`) to the [Coffee Taster's Flavor Wheel](https://sca.coffee) lexicon — the Specialty Coffee Association and World Coffee Research artifact, which names both bodies on its face — so sensory descriptors become comparable across producers instead of free strings. 4. **A water profile.** A structured brew-water specification: TDS/hardness targets or a named mineral profile ("60 ppm", Third Wave Water). It is the one brew variable recipes state that v1.0 does not model. Water travels only as mass and temperature. Waits for a producer/consumer that exchanges it. 5. **A vendor-extension member `ext`.** The named home for *third-party* data, distinct from every other entry here, which reserves a future *first-party* shape. An application with private data that belongs in the document **SHOULD NOT** invent bare members on entities this specification defines. It **SHOULD** carry the data under `ext`, keyed by a vendor identifier (`"ext": { "app.example": { … } }`), or propose the field for the format itself. `ext` contents are vendor-defined by construction, so it is the one reserved name whose *use* is permitted today. Emitting it does not pretend a v1.0 definition exists. The payoff is a clean growth path. A vendor field that proves out can be promoted to a defined optional field in a later minor, while the original `ext` data stays valid vendor data. Nothing renames. The full mechanism (identifier grammar, promotion process) is defined when a real adopter needs it. Until then the reservation is the convention. ### The version gate A consumer decides support by the **major** component of `coffeejson`: | Document major | Consumer behavior | | --- | --- | | Older or equal, supported | Process normally; ignore unknown fields. | | Same major, newer minor | Accept; rely on forward-compatibility. | | Newer major | **MAY reject.** SHOULD show a clear "unsupported version — please update" message rather than failing opaquely. | A consumer **MUST NOT** silently misinterpret a newer major version as if it were its own. ## Media type The reserved media type for a CoffeeJSON document is: ``` application/vnd.coffeejson+json ``` The `+json` structured-syntax suffix signals that the payload is JSON and can be processed by generic JSON tooling. Registration of this media type is reserved for when the format is published. Until then it is the recommended type for `Content-Type` headers and file associations. ## File extension CoffeeJSON reserves **no dedicated file extension**. A CoffeeJSON file is a plain `.json` file that contains exactly one CoffeeJSON [document](https://coffeejson.org/docs/spec/02-envelope.md), in any envelope shape: a `recipes` array, a `beans` array, or both. Type association happens through the [media type](#media-type), not the extension. See [Transport](https://coffeejson.org/docs/transport.md) for file, URL, and QR bindings. ## Conformance ### Conformant document A document is **conformant** to CoffeeJSON 1.0 if: 1. It is a JSON object with a `coffeejson` string whose major version is `1`. 2. It contains **at least one** of `beans` or `recipes`, present and non-empty (the [envelope rule](https://coffeejson.org/docs/spec/02-envelope.md)). 3. Every Recipe in `recipes` has the required `title` and `coffee`, plus its `basis`'s stated brew quantity. That is `water` **or** `ratio` for `basis: "water"` (or absent, the default) — each fixes the other against the dose. For `basis: "yield"` it is `yield` instead, with neither `water` nor `ratio` ([Recipe § Espresso](https://coffeejson.org/docs/spec/03-recipe.md#espresso-dose--yield)). Each typed field that is present matches the type given in [Recipe](https://coffeejson.org/docs/spec/03-recipe.md) / [Bean](https://coffeejson.org/docs/spec/04-bean.md). Unknown members at any level do **not** make a document non-conformant. The forward-compatibility contract explicitly permits them. The smallest conformant document is the [minimal valid document](https://coffeejson.org/docs/spec/02-envelope.md#minimal-valid-document). ### Conformant producer A producer is conformant if every document it emits is conformant and it obeys five rules. It emits required fields. It emits URL-valued fields in **URI form** (punycode hostname, percent-encoded path, the form a browser's address bar copies out). It emits the linking members — `id`, `bean_ref`, `recipe_ref` — in Unicode **NFC** normalization, which is load-bearing for the byte-exact `bean_ref` ↔ `id` match, and SHOULD emit human-text strings in NFC too ([Envelope § Association](https://coffeejson.org/docs/spec/02-envelope.md#association-explicit-reference)). It serializes [derived step labels as absent](https://coffeejson.org/docs/spec/03-recipe.md). It does not emit reserved fields ([Reserved extensions](#reserved-extensions)) as if they were defined in v1.0. ### Conformant consumer A consumer is conformant if it: - accepts every conformant document of a supported major version; - **ignores unknown** members and maps unknown enum values per each [vocabulary's rule](https://coffeejson.org/docs/spec/06-vocabularies.md), and never rejects a document because it fails schema validation on values from a newer minor ([The published schema](#the-published-schema)); - **converts** any recognized [unit](https://coffeejson.org/docs/spec/06-vocabularies.md#units) to its own canonical store and treats an unrecognized unit as absent; - **preserves step array order** and shows, rather than fails on, step kinds it does not model; - never depends on the informational `generator` field. A consumer **MAY** also validate against the [JSON Schema](https://coffeejson.org/schema/1.0), but the prose specification is authoritative where the two differ. Two RECOMMENDED behaviors complete the picture. When a consumer re-emits a document it did not author, it **SHOULD** preserve the members it did not recognize. See [Overview § Preservation on re-share](https://coffeejson.org/docs/spec/01-overview.md#preservation-on-re-share) for the round-trip / re-author distinction. A consumer that scans QR codes or accepts links **SHOULD** attempt payload extraction on any host's URL, not only its own. See [Transport § Accepting links from any host](https://coffeejson.org/docs/transport.md#accepting-links-from-any-host), which the fixture corpus backs with executable scan vectors. ## Registry governance The [open registries](https://coffeejson.org/docs/spec/06-vocabularies.md) (gear, varietal, addition type, producer role) are curated data maintained beside this specification as plain JSON (`registries/gear.json`, `registries/varietals.json`, `registries/addition-types.json`, `registries/producer-roles.json`, each served from the canonical host). Their governance is lightweight by design: - **Adding an entry is a data change, not a spec change.** A new gear slug, a varietal alias or a recommended token does **not** bump the `coffeejson` version. Producers and consumers that have not yet synced the registry fall back per the vocabulary's rule, so nothing breaks. - **Ids are stable.** Once published, a slug is not repurposed. Correcting a mistake means adding a new slug and aliasing the old one, never silently changing what a slug means. - **Country codes are not curated here.** They track ISO 3166-1 directly. If CoffeeJSON becomes a shared standard, extracting these registries into a neutral, contribution-friendly repository is the natural first governance step. ## Reporting a problem Ambiguity in this specification is a bug. If two reasonable implementers could read a sentence differently, that is worth an issue. The [fixture corpus](https://github.com/coffeejson-org/coffeejson/blob/main/fixtures/README.md) exists so a disagreement can be settled by a test rather than by an argument. --- # Integrating CoffeeJSON How to make an application speak CoffeeJSON: read documents in, write them out, and behave well beside other implementations. This guide is **non-normative**. Every rule here links to its normative home in the [specification](https://coffeejson.org/docs/README.md). Two roles are defined in the [Overview](https://coffeejson.org/docs/spec/01-overview.md#conformance-language). A **consumer** reads documents. A **producer** emits them. Most implementations are both, but the roles are independent. Importing well is valuable on its own, and so is exporting well. What conformance requires of each is specified in [Versioning § Conformance](https://coffeejson.org/docs/spec/07-versioning.md#conformance). This guide walks the same ground in build order. ## Consuming — the import checklist **1 · Accept the intake channels you have.** Every form below carries the same **envelope**: a JSON object with a `coffeejson` version marker and its `beans`, `recipes` and `tastings` collections. A document arrives in one of four forms: a plain `.json` file with media type [`application/vnd.coffeejson+json`](https://coffeejson.org/docs/spec/07-versioning.md#media-type) and no dedicated extension, a [share URL](https://coffeejson.org/docs/transport.md#share-url) (`https:///r?d=`), a QR code encoding that same URL, or an [HTTP response](https://coffeejson.org/docs/transport.md#http) whose body is the document. All four carry the identical payload, so a second channel never changes your parser. Start with the URL form, which is how documents circulate. **2 · Extract from any host.** The host in a share URL names who serves the fallback web page, never who may read the payload. Your scanner or link handler SHOULD attempt `d=` extraction on **any** `http(s)` URL, including hosts you have never seen ([Transport § Accepting links from any host](https://coffeejson.org/docs/transport.md#accepting-links-from-any-host)). **3 · Decode exactly, and reject exactly.** The decode algorithm is short and fully specified in [Transport § Encoding](https://coffeejson.org/docs/transport.md#encoding); implement it from there, where it is normative. The repository ships [scan-input test vectors](https://github.com/coffeejson-org/coffeejson/blob/main/fixtures/transport/scan-vectors.json) with expected outcomes. Run your intake against them. **Name your failures these twelve things.** Each rejecting vector states the one it expects, as a `kind` member. Those names are the format's error vocabulary, and both reference SDKs report exactly these: | Outcome | What it means | TypeScript | Swift | |---|---|---|---| | no payload | no `d` parameter, or an empty one | `no_payload` | `.noPayload` | | malformed base64 | characters outside the base64url alphabet | `malformed_base64` | `.malformedBase64` | | unrecognized encoding | first byte is neither `{` nor a zlib header | `unrecognized_encoding` | `.unrecognizedEncoding` | | damaged compression | a zlib stream that did not survive the wire | `damaged_compression` | `.damagedCompression` | | too large | past the 8192-byte cap, as sent or after inflating | `too_large` | `.tooLarge` | | not UTF-8 | the bytes are not text | `not_utf8` | `.notUTF8` | | not JSON | text, but not JSON | `not_json` | `.notJSON` | | not a document | JSON, but no usable `coffeejson` member | `not_a_document` | `.notADocument` | | unsupported version | a major your build does not support | `unsupported_version` | `.unsupportedVersion` | | empty document | neither `beans` nor `recipes` carries anything | `empty_document` | `.emptyDocument` | | not a URL | a scan that is not a URL at all — scanned input only | `not_a_url` | `.notAURL` | | not http(s) | a URL of another scheme, `javascript:` or `data:` — scanned input only | `not_http` | `.notHTTP` | **These are decode failures only.** A document that decoded and then failed *your* rules belongs to a separate vocabulary of your own. **Do not collapse them.** "Not UTF-8", "not JSON" and "not a document" are three defects with three different fixes. **4 · Gate on the major version only.** A document declares its spec version in the `coffeejson` member. Decide support by the **major** component. If the major is the same and the minor is newer, accept the document. New optional fields are invisible to you. If the major is newer, you can reject it, and show a clear "unsupported version" message rather than an opaque failure. The rules and their conformance keywords are in [Versioning § The version gate](https://coffeejson.org/docs/spec/07-versioning.md#the-version-gate). **5 · Never gate imports on schema validation.** The published [JSON Schema](https://coffeejson.org/schema/1.0) is a *producer* gate for the current minor. It rejects vocabulary values a newer minor may define, so a valid future document can fail it. The [fallback rules](https://coffeejson.org/docs/spec/06-vocabularies.md) govern import, not validation. See [Versioning § The published schema](https://coffeejson.org/docs/spec/07-versioning.md#the-published-schema). **6 · Ignore the unknown, and fall back per vocabulary.** The [forward-compatibility contract](https://coffeejson.org/docs/spec/01-overview.md#the-forward-compatibility-contract-summary) is the one rule everything else leans on. Ignore members you do not recognize, at any depth. Never reject a document over them. Unknown *values* of known fields follow each vocabulary's stated fallback, and the [index table in Vocabularies](https://coffeejson.org/docs/spec/06-vocabularies.md#index) lists every one. They come in three classes. **Map to `other`** where "not listed" is itself a usable answer (`method`, step `kind`, `process`, `form`). **Ignore the field** where a wrong guess would assert something false (`roast_level`, grind `size`, party `type`, each with its named recovery). **Derive from the document** where the data answers the question (`basis` — whether a recipe is measured to its brewed water or to what lands in the cup — from the quantities present; `origin.type` from the item count). **7 · Convert units, or treat the measurement as absent.** Units travel as canonical identifiers (`gram`, `celsius`, `bar`), never display symbols. Convert anything you recognize into your own canonical store. Treat a measurement with an unrecognized unit as **absent**. Never guess, and never show the wire identifier verbatim ([Vocabularies § Units](https://coffeejson.org/docs/spec/06-vocabularies.md#units)). **8 · Preserve step order, and show what you do not model.** A recipe's `steps` array order is authoritative. Preserve a step kind you do not model (a pour-over app that meets `tamp`) and show it read-only. See [Recipe § Mixed-capability consumers](https://coffeejson.org/docs/spec/03-recipe.md#mixed-capability-consumers). **9 · Resolve bean↔recipe association by the one rule.** An explicit `bean_ref` wins, by exact, case-sensitive match. An unresolved reference leaves the recipe unlinked, never an error. A single co-located bean associates by position. Otherwise entities are independent ([Envelope § Association](https://coffeejson.org/docs/spec/02-envelope.md#association-explicit-reference)). **10 · Preserve on re-share.** When you re-emit an unedited document you did not author, carry the members you did not recognize rather than strip them. When you rebuild a document from your own model you may drop what you do not carry, and you say so. The round-trip / re-author distinction is [Overview § Preservation on re-share](https://coffeejson.org/docs/spec/01-overview.md#preservation-on-re-share). ## Producing — the export checklist **1 · Emit canonical, locale-neutral identifiers.** Emit machine ids for everything enumerable (`pour_over`, `washed`, `gram`). The consumer renders localized labels. Emit URL-valued fields in URI form. Emit the linking members — `id`, `bean_ref`, `recipe_ref` — in Unicode **NFC**, which is load-bearing for `bean_ref` ↔ `id` matching, and human text in NFC too where you can. Emit derived step labels as absent. The compact statement of all producer obligations is [Versioning § Conformant producer](https://coffeejson.org/docs/spec/07-versioning.md#conformant-producer). **2 · Lint your output with the authoring schema.** The [authoring variant](https://coffeejson.org/schema/authoring/1.0) closes every object except the reserved `ext` member, and rejects empty optional arrays. It requires `bean_ref` on every recipe once you emit more than one bean. A typo'd field name then fails your build loudly instead of being silently ignored by every consumer forever. A second coffee cannot quietly unlink the recipes that were associated with the first. It is a producer lint only. Never validate *imports* against it. **3 · Do not emit reserved names. Put private data under `ext`.** The [reserved extensions](https://coffeejson.org/docs/spec/07-versioning.md#reserved-extensions) are named growth areas. Do not emit them as if defined. Vendor-private data is the one exception: put it under the vendor-extension member `ext`, keyed by your vendor identifier. Do not invent bare members on spec entities. **4 · Share from your own domain, and run a fallback page.** A share link lives on your domain (`yourapp.example/r?d=…`). Platform app-association binds domains to apps, so your link is your app's link. Run a fallback page that decodes client-side and renders a preview. Chat clients routinely open links in in-app browsers that bypass app association ([Transport § Deep-linking](https://coffeejson.org/docs/transport.md#deep-linking-and-the-fallback-page)). Your page SHOULD NOT log the `d` parameter ([Transport § Privacy](https://coffeejson.org/docs/transport.md#privacy-honestly)). **5 · Prefer self-contained QR codes.** A QR that carries the document in the ink works offline and needs no infrastructure. Every CoffeeJSON-aware scanner can read it regardless of the printed host ([Transport § QR code](https://coffeejson.org/docs/transport.md#qr-code)). Keep documents lean so the code stays scannable. Trim content. Never truncate a document. ## Test against the repository The [fixture corpus](https://github.com/coffeejson-org/coffeejson/blob/main/fixtures/README.md) is the executable contract: - Every document in `fixtures/valid/` must import cleanly in your consumer. - `fixtures/invalid/` documents are producer-gate rejections. Your *emitter* must never produce their shapes. Your importer stays lenient, per rule 6. - [`fixtures/transport/scan-vectors.json`](https://github.com/coffeejson-org/coffeejson/blob/main/fixtures/transport/scan-vectors.json) pins your link intake, acceptance and rejection both. - The [validator](https://coffeejson.org/validator/) checks any document in-browser against the schema. Reference implementations live beside the spec. `@coffeejson/core` (TypeScript: types, codec, a total `normalize`) and `@coffeejson/react` (renderer components) are in this repository's `packages/`. `coffeejson-swift` covers Apple platforms. They track the format and follow semver on their own clock. See [Versioning & conformance](https://coffeejson.org/docs/spec/07-versioning.md#what-you-can-rely-on-today). ## List your implementation [`registries/implementations.json`](https://coffeejson.org/registries/implementations.json) lists what speaks CoffeeJSON (apps, hosted services, libraries, machines) and the transport surfaces each one `reads` and `writes`. Fallback pages use the reading half to offer "open in your app" handoffs. Something that only *publishes* documents belongs there as much as something that only imports them. A one-line pull request adds yours. An optional `icon`, a square image added under the site's `public/showcase/` in the same pull request, appears beside your name on the showcase. Registration buys visibility, never interoperability. --- # Transport A CoffeeJSON [document](https://coffeejson.org/docs/spec/02-envelope.md) is JSON. It is **transport-agnostic**: the data model says nothing about how the bytes get from one place to another. This document describes the recommended *bindings*, ways to move a document that interoperate cleanly. They are conventions, not part of the core format. A producer is free to use any of them or none. Four bindings are described here: | Binding | Best for | Infra | | --- | --- | --- | | [File](#file) | Backup, library export, attachments | none | | [Share URL](#share-url) | Sharing one recipe over any channel | a fallback page | | [QR code](#qr-code) | Print, in-person, bag-to-cup | none | | [HTTP](#http) | A catalog, or an endpoint that already exists | a server | All four carry the **identical payload**, the same JSON document, so adding one later never requires a format change. ## File A document can be written to a plain `.json` file with the media type [`application/vnd.coffeejson+json`](https://coffeejson.org/docs/spec/07-versioning.md#media-type). CoffeeJSON reserves [no dedicated extension](https://coffeejson.org/docs/spec/07-versioning.md#file-extension). - A single-recipe file is a `recipes` array of one. A library export is a `recipes` array of many. A bag-to-brew file pairs a one-element `beans` array with `recipes`. - This is the simplest binding: no encoding beyond UTF-8 JSON. A file **MUST** be encoded in UTF-8 and **MUST NOT** begin with a byte-order mark ([RFC 8259 § 8.1](https://www.rfc-editor.org/rfc/rfc8259#section-8.1)). A consumer that meets one **SHOULD** discard it and read the rest, and **MUST NOT** reject the document for the mark alone — editors and runtimes on some platforms write one without asking, and JSON parsers disagree about it. It suits backups, email attachments, and AirDrop-style handoffs. - On platforms that route files **by type** with a user chooser, the file is also the app-neutral handoff. Any app that claims the type can receive it, and the *recipient* picks which. It matters as soon as more than one CoffeeJSON-aware app exists on a device. ## Share URL A document can be made **self-contained** inside a URL by carrying the whole payload in a query parameter: ``` https:///r?d= ``` For example: ``` https://coffeejson.org/r?d=eyJjb2ZmZWVqc29uIjoiMS4wIiwicmVjaXBlcyI6W3sidGl0bGUiOiJFdmVyeWRheSBWNjAiLCJjb2ZmZWUiOnsidmFsdWUiOjE1LCJ1bml0IjoiZ3JhbSJ9LCJ3YXRlciI6eyJ2YWx1ZSI6MjUwLCJ1bml0IjoiZ3JhbSJ9fV19 ``` which decodes to: ```json { "coffeejson": "1.0", "recipes": [ { "title": "Everyday V60", "coffee": { "value": 15, "unit": "gram" }, "water": { "value": 250, "unit": "gram" } } ] } ``` The payload rides in the **query**, never the fragment. Chat and social clients linkify a URL only up to `#`, so a fragment-carried document is dropped at the tap. Nothing defines or emits a fragment form. A consumer parses `?d=` and nothing else. Two general rules regardless of binding: - **Prefer `https://` links over custom URI schemes for sharing.** Clients reliably linkify only `http(s)://`. A custom scheme (`myapp://…`) is commonly left un-linkified or truncated. Reserve custom schemes for in-app or on-device handoffs. - **The portability is in the payload, not the URL.** The same `d=` value decodes in any consumer, whatever host serves the link. ### Accepting links from any host The rules above are the producer's side. The consumer's mirror image: a consumer that scans QR codes or accepts pasted or shared links **SHOULD** attempt payload extraction on **any** `http(s)` URL. It parses the URL, reads the `d` query parameter, and decodes per [Encoding](#encoding), regardless of the URL's host, including hosts it has never seen. The host names who serves the [fallback page](#deep-linking-and-the-fallback-page), never who may read the payload. A scanner hands you **text**, not a URL, and text that is not a URL at all is the ordinary case. So the first two steps are checks rather than parsing. Reject what is not a URL, then what is not `http(s)`. The scheme check comes **before** the payload is read. A `javascript:` or `data:` URL can carry a well-formed payload, and an implementation that decodes first has already treated it as a share link. A consumer that then opens what it scanned is one step from executing it. Both cases are named in the [scan vectors](https://github.com/coffeejson-org/coffeejson/blob/main/fixtures/transport/scan-vectors.json). The reference implementations expose the whole binding as one call: `decodeScanned(text)` in `@coffeejson/core`, and `ShareLink.importDocument(fromScanned:)` in `coffeejson-swift`. An adopter does not have to reassemble it from this prose. A scanner that recognizes only its own domain lets any other producer's QR fall through to the browser. The user lands on *that producer's* fallback page instead of importing into the app in hand. The fixture corpus ships [scan-input test vectors](https://github.com/coffeejson-org/coffeejson/blob/main/fixtures/transport/scan-vectors.json): own-host, foreign-host, extra-parameter, malformed, non-UTF-8, oversized, and empty-envelope inputs, each with its expected outcome. A rejecting vector carries a `kind`, the name this failure has in the [failure vocabulary](https://coffeejson.org/docs/integration-guide.md) both reference SDKs report, so an implementation asserts on the vector's own word rather than on a name table of its own. A consumer can test its link intake against this contract directly, and the repository's own harness executes them. ### Privacy, honestly - When a share link opens **app-to-app** on a device (platform app association), the operating system hands the URL to the app directly. The payload takes no server round-trip. - Under the query binding, the payload **does** reach the fallback host when the link is opened in a browser. That happens with no app installed, or in an in-app browser that bypasses app association. The trade-off buys a link that works in chat channels, and it lets the fallback page render a server-side preview if its operator chooses to. - A host that serves a fallback page **SHOULD NOT** log the `d` parameter. - A recipe is low-sensitivity data. A producer that cannot let a payload reach any server does not put it in a URL at all. It uses the file binding, which never leaves the device unless the user sends it. ### Encoding - **A payload is `base64url(JSON)` or `base64url(zlib(JSON))`.** The JSON document is serialized as UTF-8, optionally compressed with **zlib** ([RFC 1950](https://www.rfc-editor.org/rfc/rfc1950)), then encoded with URL-safe Base64 ([RFC 4648 §5](https://www.rfc-editor.org/rfc/rfc4648#section-5): `-` and `_` instead of `+` and `/`), with padding `=` omitted. The two forms are told apart by the first decoded byte. See [Compression](#compression). - **A producer MAY emit either form, and SHOULD compress.** Compression is the difference between a share link that survives a chat client and one that gets truncated. It is also the difference between a document that fits a scannable QR code and one that does not. A producer that cannot compress is still conformant: a build step without a compressor, or an encoder that must stay synchronous on a platform with an asynchronous compression API. So a producer adopts compression one surface at a time. - **A consumer MUST read both forms.** This is not a choice. Plain payloads are legal permanently, so a reader that handles only the compressed form is as broken as one that handles only the plain form. Both appear in the [scan vectors](https://github.com/coffeejson-org/coffeejson/blob/main/fixtures/transport/scan-vectors.json). - **Decoding MUST be UTF-8-aware.** In JavaScript, translate `-_` → `+/` and re-pad to a multiple of four. Base64-decode to *bytes*. Decompress if the first byte says so. Then decode the resulting bytes with `TextDecoder("utf-8")`. `atob` output read directly as text yields Latin-1 and mangles any non-ASCII title. - **A zero-byte inflate is a parse failure, not a compression failure.** A well-formed zlib stream that inflates to nothing has passed both the discriminator and the inflate, so it fails one step later, at the parser, like any other payload that is not JSON. - A typical recipe is a few hundred bytes, well within practical URL-length limits across messaging apps, social posts, and email. Compression roughly halves a real document, and the saving grows with the document. - A consumer **MUST** reject a payload that is malformed Base64 or carries an unrecognized encoding. It **MUST** also reject one that decodes to invalid JSON or yields a JSON document larger than **8192 bytes**, the normative size cap and a guard against pathological input. A richly populated bag-to-brew document stays well under it. The [scan vectors](https://github.com/coffeejson-org/coffeejson/blob/main/fixtures/transport/scan-vectors.json) pin the boundary, so two conformant consumers always agree on whether a given link imports. - **The cap is enforced on the JSON document, so a compressed payload MUST be decompressed under a bound.** Stop at 8192 output bytes and reject. Never decompress fully and measure afterwards. For a plain payload the encoded length bounds the document, because Base64 expands by a fixed ratio. Compression severs that relation, and a kilobyte of payload can carry megabytes of output. ### Deep-linking and the fallback page How a URL opens a specific app is a platform concern (deep-link / app-association mechanisms), outside the scope of CoffeeJSON. Two consequences shape what a share link can do: - **Routing is per-app, so a share link is the producing app's link.** Platform app association (Apple Universal Links, Android App Links) binds a **domain to a fixed set of apps**, never a content type to "whichever app handles CoffeeJSON." A self-contained link lives on the producing app's (or a format host's) domain. There is no neutral domain that opens an arbitrary recipient's app. The [file binding](#file) is the content-type route that *does* let a recipient choose their app. - **The fallback page is effectively required, not optional.** Chat and social clients routinely open links in **in-app browsers** that bypass app association and never reach the app. A page at `/r` that decodes the payload client-side, renders a read-only preview, and offers explicit open/save actions is the only recovery for that case and for recipients with no app. Under the query binding it can also render a server-side preview (for example link-unfurl cards) if the operator chooses. ## QR code A QR code encodes the **same payload** as the share URL: either the self-contained `https://…/r?d=…` URL or, for print reliability at higher data density, a short hosted URL that resolves to the document. - A **self-contained QR** needs no infrastructure. The document travels in the ink. A scan with a phone camera opens the fallback page, which renders the recipe. An app that scans QR codes itself can decode the `d=` payload directly, offline, with no registration anywhere, from [any host's](#accepting-links-from-any-host) QR and not only its own. The cost is a denser code as the payload grows. - A **hosted-URL QR** stays sparse and is updatable after printing. The cost is a resolver and a network connection to fetch the document. The choice is the producer's. The encoded document is identical either way. The binding is host-agnostic. A roaster prints the QR against its **own domain** if it wants the scan to land on its page, or against a neutral format host. Every CoffeeJSON-aware reader decodes the same `d=` payload regardless of host. ## HTTP A document can be served over HTTP at a URL, like any other resource. This is the binding for a producer that already runs a service: an endpoint that returns a recipe adds a CoffeeJSON representation of it, and every CoffeeJSON-aware consumer can read the result. - A producer sends [`application/vnd.coffeejson+json`](https://coffeejson.org/docs/spec/07-versioning.md#media-type) as the `Content-Type`. The body is plain JSON. This binding adds no Base64 and no encoding of its own; how the bytes are compressed on the wire is HTTP's business, not the format's. - A consumer fetches the body, parses it, and then applies exactly what it applies to a decoded payload: the same envelope rules, in the same order, reported in the same [failure vocabulary](https://coffeejson.org/docs/integration-guide.md). Both reference SDKs expose that step on its own — `checkEnvelope(value)` in `@coffeejson/core` — because a body from HTTP, a POST it received, and an opened file all arrive already parsed. - **The 8192-byte cap does not apply here.** It exists because a payload in a URL has to survive a URL, and it is stated in [Encoding](#encoding) for that reason. A fetched body is bounded by whatever bounds the client's other responses. - A [hosted-URL QR](#qr-code) resolves to exactly this. The trade-off is the mirror of the self-contained forms'. A hosted document can be corrected after the link is shared, so printed matter pointing at it stays current — and it stops working when its host does, or when the reader is offline. That durability is what the [file](#file) and the self-contained [share URL](#share-url) buy, and what they give up. The choice is the producer's, and a producer can offer both: the document is the same either way. ## Compression The payload of a share URL or QR code **MAY** be compressed with **zlib** ([RFC 1950](https://www.rfc-editor.org/rfc/rfc1950)) before Base64. The compression covers the payload and nothing else. A document in a file, an HTTP body, a clipboard, or a `.json` attachment is plain JSON, unchanged. **Why zlib.** Its first byte is `(CINFO << 4) | CM`, and **CM is always 8** for deflate. The low nibble cannot be `B`, so a zlib stream can never begin `{`. That makes the discriminator below structural and free. Raw DEFLATE gives no such guarantee. **The discriminator.** The two forms are told apart by **one byte, after the Base64 decode**. A consumer **MUST** commit to the branch that byte selects: - A decoded payload whose first byte is `{` (0x7B) is an uncompressed JSON document. This form is legal permanently. No link ever minted changes meaning. - A decoded payload whose first byte has a **low nibble of 8** and whose first two bytes satisfy zlib's own header check is a zlib stream. That check is `(b0 << 8 | b1) % 31 == 0`, with the preset-dictionary bit clear — `(b1 & 0x20) == 0`. Decompress it under a bound (see [Encoding](#encoding)), then parse the result. - A decoded payload that begins with **any other byte** is an unrecognized encoding. A consumer **MUST** reject it (the same class as malformed Base64), and never attempt to parse or guess. Two rules that decide whether an implementation is correct: - **Test the nibble, not the byte.** Every common compressor emits `0x78`, a 32-KiB window, but a producer with a smaller window legitimately emits `0x08` through `0x68`. A consumer that tests for `0x78` exactly rejects a valid payload. The nibble test is two lines and can never swallow a JSON document. - **Never parse first and fall back on error.** A JSON parse attempt with decompression only on failure puts back the ambiguity the discriminator exists to remove. It turns a malformed payload into a guess about its encoding. Dispatch, then commit. **What a reader needs.** A share-link consumer needs a Base64 decoder, a zlib inflate, and a JSON parser. The inflate is the one addition, and not an exotic one. Every browser ships `DecompressionStream`, and every server runtime and mobile platform has an equivalent. What it costs is legibility. A plain payload can be pasted into a console and read, and a compressed one cannot. That cost is confined to this transport. In a file, an HTTP body, or a clipboard, a CoffeeJSON document is still plain JSON. ---