openapi: 3.1.0
info:
  title: Wine Chemistry & Compliance API
  version: "1.0.0"
  summary: US federal limits on what a wine may contain, the materials TTB authorises for treating it, and the cellar chemistry a QC lab runs daily.
  description: |
    Post a lab panel; get back which federal limits it breaches, the arithmetic, the citation,
    and the regulation sentence verbatim so you can check this service against eCFR.

    **What it covers.** The composition maxima of 27 CFR part 4 — volatile acidity by wine class
    and colour (§4.21), the 350 ppm total sulfur dioxide ceiling (§4.22), and the 10 ppm
    threshold that forces a sulfite declaration (§4.32) — plus every material authorised for
    treating wine under §24.246 with its specific TTB limitation, and the amelioration and
    cellar-treatment sections of part 24.

    **It does NOT predict wine quality.** The brief for this service asked for a model over the
    UCI Wine Quality dataset. That dataset is CC BY 4.0, an attribution obligation this fleet
    does not accept, and no CC0 chemistry-to-sensory dataset exists — USDA FoodData Central has
    wine composition but no quality scores, so there is no training target. There is no model
    here and no prediction endpoint. The full reasoning is served at `/v1/sources`.

    **A missing measurement is never a zero.** Molecular SO2 needs pH; without it the answer is
    `null` with a reason, because a lab reading `molecularSO2: 0` would add sulfur the wine does
    not need. The same rule governs every computed figure.

    **Silence is not a pass.** A wine whose class no federal rule covers is reported as
    `notApplicable` with `compliant: null` — never as compliant.

    **Not official, and federal only.** Not affiliated with, endorsed by or certified by TTB or
    the Office of the Federal Register. This reports what the CFR says; it is not legal advice,
    not a TTB determination, and a state may impose stricter limits.
  license:
    name: MIT (server) — data is a work of the US Government, public domain
    url: https://apis.allanninal.dev/chem/license.html
  contact:
    name: Allan Niñal
    url: https://www.linkedin.com/in/allanninal/
servers:
  - url: https://apis.allanninal.dev/chem
    description: Production
tags:
  - name: Compliance
    description: Check a lab panel against the federal limits.
  - name: Limits
    description: The federal composition maxima, with their regulation text.
  - name: Materials
    description: Materials authorised for treating wine under 27 CFR 24.246.
  - name: Chemistry
    description: Deterministic cellar calculations.
  - name: Reference
    description: Production sections, sources, plans and versions.

paths:
  /healthz:
    get:
      tags: [Reference]
      summary: Liveness, counts, and what this service does not do
      responses:
        "200": { description: Healthy. }

  /v1/lab/check:
    post:
      tags: [Compliance]
      summary: Check a lab panel against the federal composition limits
      description: |
        Every analyte lands in exactly one of `checks`, `notApplicable` or `notMeasured` —
        nothing is silently dropped, and an absent value is never treated as zero.

        A measurement sent as a quoted string is REJECTED with a 400 rather than coerced: in a
        QC system that is a data-entry error, and coercing it would put a wrong compliance
        verdict into a winery's records.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                wineClass: { type: string, enum: [grape, fruit], example: grape }
                colour: { type: string, enum: [red, white, other], example: red }
                ameliorated: { type: boolean, description: "Required for the 28 °Brix proviso to apply. Undeclared means the proviso does NOT apply." }
                addedSpirits: { type: boolean }
                volatileAcidity: { type: number, example: 0.15, description: "g/100 mL, as acetic acid." }
                totalSO2: { type: number, example: 372, description: "ppm." }
                freeSO2: { type: number, example: 28 }
                pH: { type: number, example: 3.62 }
                brix: { type: number, example: 24.1 }
      responses:
        "200":
          description: The per-analyte result.
          content: { application/json: { schema: { $ref: './api/v1/schemas/lab-check.schema.json' } } }
        "400": { description: A measurement was not a number, or was negative. }

  /v1/limits:
    get:
      tags: [Limits]
      summary: Every federal composition limit
      parameters:
        - { name: analyte, in: query, schema: { type: string }, example: volatile-acidity }
        - { name: wineClass, in: query, schema: { type: string, enum: [grape, fruit] } }
        - { name: kind, in: query, schema: { type: string, enum: [composition-limit, labelling-trigger] } }
        - { $ref: '#/components/parameters/limit' }
        - { $ref: '#/components/parameters/cursor' }
      responses:
        "200": { description: Matching limits, each with its verbatim regulation sentence. }

  /v1/limits/{analyte}:
    get:
      tags: [Limits]
      summary: Every limit governing one analyte
      description: |
        More than one limit may govern an analyte — volatile acidity has six, differing by wine
        class, colour and whether the juice was ameliorated. All are returned, because which one
        applies is a property of the wine, not of the analyte. Use `POST /v1/lab/check` to have
        the governing one selected.
      parameters:
        - { name: analyte, in: path, required: true, schema: { type: string }, example: volatile-acidity }
      responses:
        "200": { description: The limits. }
        "404": { description: No published limit for that analyte; the response lists what is known. }

  /v1/materials:
    get:
      tags: [Materials]
      summary: Materials authorised for treating wine or juice
      description: |
        One record per material-and-use: the TTB limit differs by use, so collapsing them would
        force a single limit onto uses that have different ones. `hasNumericLimit` and
        `crossReferenceOnly` distinguish a stated quantity from a cross-reference, so you never
        have to parse the limitation text to find out whether there is a number in it.
      parameters:
        - { name: q, in: query, schema: { type: string }, example: sorbic }
        - { name: hasLimit, in: query, schema: { type: boolean }, description: "Only uses whose limitation states a quantity." }
        - { name: crossReferenceOnly, in: query, schema: { type: boolean } }
        - { $ref: '#/components/parameters/limit' }
        - { $ref: '#/components/parameters/cursor' }
      responses:
        "200": { description: Matching materials. }

  /v1/materials/{id}:
    get:
      tags: [Materials]
      summary: One authorised material-and-use
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: The material. }
        "404": { description: "Not an authorised material. 27 CFR 24.246 is a closed list, so absence is itself the answer." }
        "409": { description: "That name has several authorised uses, each with its own TTB limit. The candidates are returned rather than one picked arbitrarily." }

  /v1/production:
    get:
      tags: [Reference]
      summary: Amelioration, sweetening and cellar-treatment sections of 27 CFR part 24
      description: Quantities are returned WITH the sentence that conditions them; a threshold stripped of its conditions is worse than no threshold.
      parameters:
        - { name: section, in: query, schema: { type: string }, example: "24.178" }
        - { name: hasQuantities, in: query, schema: { type: boolean } }
        - { $ref: '#/components/parameters/limit' }
        - { $ref: '#/components/parameters/cursor' }
      responses:
        "200": { description: Matching sections. }

  /v1/calc/molecular-so2:
    post:
      tags: [Chemistry]
      summary: Molecular SO2 from free SO2 and pH
      description: |
        `molecular = free / (1 + 10^(pH − pKa))`, pKa 1.81. The figure a cellar acts on, and the
        one most often got wrong: the same free SO2 is roughly twice as effective at pH 3.2 as
        at pH 3.6.

        **pH is required.** Without it the response is `200` with `value: null` and a reason —
        the caller asked a valid question about an incomplete panel, and the honest answer is
        "not from this", not a client error and certainly not zero.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                freeSO2: { type: number, example: 28 }
                pH: { type: number, example: 3.4 }
                pKa: { type: number, default: 1.81 }
      responses:
        "200": { description: The molecular SO2, or an explained null. }

  /v1/calc/potential-alcohol:
    post:
      tags: [Chemistry]
      summary: Potential alcohol from degrees Brix
      description: |
        Returns a RANGE, not a number. The conversion depends on yeast, fermentation temperature
        and final dryness, and every published single factor between 0.55 and 0.59 is someone's
        average. Pass `factor` to compute with your own. Not for a label declaration: 27 CFR 4.36
        allows only a narrow tolerance and requires a measured value.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                brix: { type: number, example: 24 }
                factor: { type: number, description: "Your own conversion factor. Supplying it returns a single value instead of a range." }
      responses:
        "200": { description: The range, or a single value when you supplied a factor. }

  /v1/calc/convert:
    post:
      tags: [Chemistry]
      summary: Convert between the units these limits use
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                value: { type: number, example: 0.14 }
                from: { type: string, example: "g/100mL" }
                to: { type: string, example: "g/L" }
      responses:
        "200": { description: The converted value, or an explained null for an unknown unit pair. }

  /v1/changes:
    get:
      tags: [Reference]
      summary: What changed in the published limits since a date
      description: |
        A convenience, not a monopoly. eCFR publishes a dated point-in-time archive of title 27,
        so the underlying history is not this service's alone — what is retained here is the
        PARSED and DIFFED form.
      parameters:
        - { name: since, in: query, schema: { type: string, format: date }, example: "2026-01-01" }
        - { name: dataset, in: query, schema: { type: string, enum: [limits, materials, production] } }
      responses:
        "200": { description: The change feed. }
        "422": { description: Before this service began retaining history; the response points at the eCFR archive for that date. }
        "503": { description: This instance has no history store attached. }

  /v1/history/coverage:
    get:
      tags: [Reference]
      summary: How far back the retained history reaches
      responses:
        "200": { description: Coverage per dataset. }
        "503": { description: This instance has no history store attached. }

  /v1/sources:
    get:
      tags: [Reference]
      summary: Every upstream used, and everything rejected with its reason
      description: Includes the full reasoning for why this service has no quality-prediction model.
      responses:
        "200": { description: The source manifest. }

  /v1/plans:
    get:
      tags: [Reference]
      summary: The plan catalogue
      responses:
        "200": { description: Plans. Every plan reaches every endpoint; they differ only in volume. }

  /v1/version:
    get:
      tags: [Reference]
      summary: Contract version, dataset versions and the CFR issue date
      responses:
        "200": { description: Versions. }

  /v1/usage:
    get:
      tags: [Reference]
      summary: Your usage and limits
      responses:
        "200": { description: Usage. }

components:
  parameters:
    limit:
      name: limit
      in: query
      description: Page size. Clamped to your plan's maximum rather than rejected.
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous response's nextCursor. Do not construct one.
      schema: { type: string }

  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: |
        Optional. Every read path here is anonymous and free; a key only raises the per-IP
        budget and meters you individually. It unlocks nothing.

  responses:
    Problem:
      description: An RFC 9457 problem document.
      content:
        application/problem+json:
          schema:
            type: object
            properties:
              type: { type: string, format: uri }
              title: { type: string }
              status: { type: integer }
              detail: { type: string }

  schemas:
    Limit:
      $ref: './api/v1/schemas/limit.schema.json'
    Material:
      $ref: './api/v1/schemas/material.schema.json'
    LabCheck:
      $ref: './api/v1/schemas/lab-check.schema.json'

security:
  - {}
  - bearerKey: []
