🥤BevFacts

Technical / Part 4

Part 4 — Data Model & Schema

The canonical JSON record of one as-ordered drink: what every field means, the machine-validatable schema, and the serialization rules that make documents hashable.

DBNF-1 · Part 4 Normative Schema ID: dbnf-1.schema.json

4.1The DBNF document

One document = one as-ordered drink. It carries enough to re-derive the label (base + modifiers), plus the engine's output (computed, declared, warnings) so consumers of the document don't need an engine.

{
  "dbnf": "1.0-draft",
  "drink": {
    "name": "Caramel Latte",
    "vendorSku": "lat-car-16",
    "servingSize": { "amount": 16, "unit": "fl_oz", "ml": 473 },
    "servingsPerContainer": 1
  },
  "base": {
    "calories": 250, "totalFat_g": 7, "satFat_g": 4.5, "transFat_g": 0,
    "cholesterol_mg": 25, "sodium_mg": 170, "totalCarb_g": 35,
    "fiber_g": 0, "sugars_g": 33, "addedSugars_g": 28,
    "protein_g": 10, "caffeine_mg": 150
  },
  "modifiers": [
    { "type": "milk_substitution", "from": "reduced_2pct", "to": "oat_barista", "milkVolume_ml": 250 },
    { "type": "sweetness",     "factor": 1.0, "method": "total_sugars_approx" },
    { "type": "syrup_pump",    "count": 3, "syrup": "vanilla", "sugars_g": 5, "calories": 20 },
    { "type": "espresso_shot", "count": 2, "caffeine_mg": 75 }
  ],
  "computed": {
    "calories": 320, "totalFat_g": 7.25, "satFat_g": 2.0, "transFat_g": 0,
    "cholesterol_mg": 25, "sodium_mg": 170, "totalCarb_g": 54.5,
    "fiber_g": 0, "sugars_g": 43, "addedSugars_g": 43,
    "protein_g": 5, "caffeine_mg": 300,
    "dv": { "totalFat_pct": 9, "satFat_pct": 10, "cholesterol_pct": 8,
            "sodium_pct": 7, "totalCarb_pct": 20, "fiber_pct": 0,
            "addedSugars_pct": 86 },
    "sizeMethod": "published_recipe",
    "sweetnessMethod": "total_sugars_approx"
  },
  "declared": {
    "calories": "320", "totalFat": "7g", "satFat": "2g", "transFat": "0g",
    "cholesterol": "25mg", "sodium": "170mg", "totalCarb": "55g",
    "fiber": "0g", "sugars": "43g", "addedSugars": "43g",
    "protein": "5g", "caffeine": "300mg"
  },
  "warnings": ["added_sugars_over_50pct_dv", "caffeine_over_half_daily"],
  "cup": {
    "id": "A92F-3",
    "deposit": { "amount": 1.00, "currency": "USD" },
    "state": "in_use", "cycles": 14
  },
  "meta": {
    "generatedAt": "…",                       // RFC 3339 UTC
    "engine": "bevfacts-ref/1.0",
    "vendor": { "name": "Example Coffee", "locationId": "loc_042" },
    "labelId": "lbl_9f2ke1"
  }
}

4.2Entity reference

FieldTypeReq.Semantics
dbnfstringMUSTSpec version the document was computed under (§4.6)
drink.namestring ≤ 80MUSTAs-ordered display name
drink.vendorSkustringMAYVendor's recipe identifier — enables audit against the recipe DB
drink.servingSizeobjectMUSTunit ∈ {fl_oz, ml}; ml always present regardless of display unit
baseNutrientVectorMUSTUnmodified recipe, unrounded, all 12 components present (zeros explicit)
modifiers[]Modifier[]MUSTIn pipeline order (Part 2 §2.2); empty array = as-published drink
computedNutrientVector + dv + methodsMUSTEngine output, unrounded; method flags record estimation fallbacks
declaredobject of stringsSHOULDPart 3 §3.5 grammar, print-ready
warnings[]enum[]SHOULDClosed set from Part 3 §3.6 + Part 2 §2.7
cupCupMAYPart 6; omit entirely for disposable service
meta.generatedAtRFC 3339MUSTUTC, second precision
meta.enginestringSHOULDname/version of the computing engine

4.3Modifier types (closed union)

Unknown type values MUST be rejected (422), not skipped — silently dropping a modifier understates the drink.

typeRequired fieldsOptional fields
sizeml_orderedml_base (else from servingSize)
ice_levellevel ∈ {none, light, regular, extra}factor (vendor-measured override)
milk_substitutionfrom, to, milkVolume_mlfromVector, toVector (per-100 mL overrides)
sweetnessfactor ∈ [0, 1.5]method
syrup_pumpcount ∈ [0, 12]syrup, sugars_g, calories, full vector
espresso_shotcount ∈ [0, 8]caffeine_mg, decaf (bool)
toppingidvector (else vendor topping table)

4.4JSON Schema (2020-12)

The machine-validatable schema. Abbreviated only where noted; the pattern for every nutrient key is identical.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bevfacts.com/technical/dbnf-1.schema.json",
  "title": "DBNF Document",
  "type": "object",
  "required": ["dbnf", "drink", "base", "modifiers", "computed", "meta"],
  "properties": {
    "dbnf": { "type": "string", "pattern": "^1\\.0(-draft(\\.\\d+)?)?$" },
    "drink": {
      "type": "object",
      "required": ["name", "servingSize"],
      "properties": {
        "name": { "type": "string", "maxLength": 80 },
        "vendorSku": { "type": "string" },
        "servingSize": {
          "type": "object",
          "required": ["amount", "unit", "ml"],
          "properties": {
            "amount": { "type": "number", "exclusiveMinimum": 0 },
            "unit": { "enum": ["fl_oz", "ml"] },
            "ml": { "type": "number", "exclusiveMinimum": 0 }
          }
        },
        "servingsPerContainer": { "type": "integer", "minimum": 1, "default": 1 }
      }
    },
    "base": { "$ref": "#/$defs/nutrientVector" },
    "modifiers": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["type"],
        "oneOf": [
          { "properties": { "type": { "const": "size" },
              "ml_ordered": { "type": "number", "exclusiveMinimum": 0 } },
            "required": ["type", "ml_ordered"] },
          { "properties": { "type": { "const": "ice_level" },
              "level": { "enum": ["none", "light", "regular", "extra"] } },
            "required": ["type", "level"] },
          { "properties": { "type": { "const": "milk_substitution" },
              "from": { "type": "string" }, "to": { "type": "string" },
              "milkVolume_ml": { "type": "number", "minimum": 0 } },
            "required": ["type", "from", "to", "milkVolume_ml"] },
          { "properties": { "type": { "const": "sweetness" },
              "factor": { "type": "number", "minimum": 0, "maximum": 1.5 } },
            "required": ["type", "factor"] },
          { "properties": { "type": { "const": "syrup_pump" },
              "count": { "type": "integer", "minimum": 0, "maximum": 12 } },
            "required": ["type", "count"] },
          { "properties": { "type": { "const": "espresso_shot" },
              "count": { "type": "integer", "minimum": 0, "maximum": 8 } },
            "required": ["type", "count"] },
          { "properties": { "type": { "const": "topping" },
              "id": { "type": "string" } },
            "required": ["type", "id"] }
        ]
      }
    },
    "computed": { "$ref": "#/$defs/nutrientVector" },
    "declared": { "type": "object", "additionalProperties": { "type": "string" } },
    "warnings": {
      "type": "array",
      "items": { "enum": [
        "added_sugars_over_50pct_dv", "added_sugars_over_100pct_dv",
        "caffeine_over_half_daily", "caffeine_over_daily",
        "clamped_negative", "energy_reconciliation_failed" ] }
    },
    "cup": {
      "type": "object",
      "required": ["id"],
      "properties": {
        "id": { "type": "string", "pattern": "^[0-9A-F]{4,8}(-[0-9A-F])?$" },
        "deposit": {
          "type": "object", "required": ["amount", "currency"],
          "properties": {
            "amount": { "type": "number", "minimum": 0 },
            "currency": { "type": "string", "pattern": "^[A-Z]{3}$" }
          }
        },
        "state": { "enum": ["issued", "in_use", "returned", "washing", "clean", "lost", "retired"] },
        "cycles": { "type": "integer", "minimum": 0 }
      }
    },
    "meta": {
      "type": "object", "required": ["generatedAt"],
      "properties": {
        "generatedAt": { "type": "string", "format": "date-time" },
        "engine": { "type": "string" },
        "labelId": { "type": "string" }
      }
    }
  },
  "$defs": {
    "nutrientVector": {
      "type": "object",
      "required": ["calories", "totalFat_g", "satFat_g", "transFat_g",
                   "cholesterol_mg", "sodium_mg", "totalCarb_g", "fiber_g",
                   "sugars_g", "addedSugars_g", "protein_g", "caffeine_mg"],
      "properties": {
        "calories":       { "type": "number", "minimum": 0 },
        "totalFat_g":     { "type": "number", "minimum": 0 },
        "satFat_g":       { "type": "number", "minimum": 0 },
        "transFat_g":     { "type": "number", "minimum": 0 },
        "cholesterol_mg": { "type": "number", "minimum": 0 },
        "sodium_mg":      { "type": "number", "minimum": 0 },
        "totalCarb_g":    { "type": "number", "minimum": 0 },
        "fiber_g":        { "type": "number", "minimum": 0 },
        "sugars_g":       { "type": "number", "minimum": 0 },
        "addedSugars_g":  { "type": "number", "minimum": 0 },
        "protein_g":      { "type": "number", "minimum": 0 },
        "caffeine_mg":    { "type": "number", "minimum": 0 }
      }
    }
  }
}

Cross-field invariants (added ≤ sugars ≤ carbs, energy reconciliation) exceed JSON Schema's expressiveness — they are enforced by the engine (Part 2 §2.7) and re-checked at the API boundary (Part 5 §9.1, error nutrient_conflict).

4.5Canonical serialization

For hashing, signing, and deduplication, a canonical byte form is defined (profile of RFC 8785 JCS):

  1. UTF-8, no BOM; object keys sorted lexicographically at every level;
  2. no insignificant whitespace;
  3. numbers in shortest round-trip form (7.25, never 7.250); no NaN/Infinity — an engine that produces them is broken upstream;
  4. timestamps in UTC with Z suffix, second precision;
  5. the document hash is sha256(canonical_bytes), rendered lowercase hex — used as the dedup key and the optional print-audit reference.

4.6Document versioning

The dbnf field pins a document to the spec version it was computed under. Readers MUST accept unknown optional fields (forward compatibility) and MUST reject documents whose major version they don't implement. A document is immutable once issued: recomputing (e.g. a recipe correction) produces a new document with a new generatedAt and hash — the old label on the old cup stays honest about what was known when it printed.