openapi: 3.1.0

info:
  title: DATOM public read only interface
  version: unversioned
  summary: What DATOM has already published, as JSON, with no login.
  description: |
    This document describes the public, read only routes that exist today. It
    is not a product API. It was written from the service's own code and
    checked with a live GET to each route on 2 November 2026 (US Eastern).
    Where a response shape comes from the code and not from a live response,
    the schema says so.

    **Read only.** Every operation here is a GET. Nothing here writes, submits
    a claim, starts a check or an analysis, or calls a model.

    **No login.** No key, token or account is accepted or needed. Nothing
    private is served: the routes return public records only, and a private
    record is answered with 404 as if it did not exist.

    **Unversioned.** There is no `/v1`. Fields can be added, renamed or removed
    without notice, and no deprecation period is promised. The static files are
    regenerated when a record is published or rescored, so a verdict or grade
    can change between two reads. Read `revisions` in a claim file for the
    history DATOM has recorded.

    **No service level.** There is no uptime commitment, no support channel for
    this interface, and no keyed or higher volume tier. Anything beyond what is
    on this page, such as keyed access or write access, is not built.

    **Rate limits.** See `docs/api/README.md`. In short: the CDK sets no
    per route throttle on the three API routes below, and the static files are
    served from a CDN with a ten minute cache.

    **Cross origin use.** The API sends `Access-Control-Allow-Origin:
    https://datom.science`, so a browser page on another origin cannot read it.
    Calls from a server, a script or an MCP client are not affected. The static
    files send `Access-Control-Allow-Origin: *`.

    Anything not listed here, including every route that needs a login, is not
    part of this interface and may change or disappear.
  contact:
    name: DATOM
    url: https://datom.science

servers:
  - url: https://ebv5sdivvc.execute-api.us-east-1.amazonaws.com
    description: The API. Routes that need no login are listed below.

tags:
  - name: Static files
    description: JSON files served by the website at https://datom.science.
  - name: Records
    description: Records served by the API.
  - name: Figure Records
    description: Claims that state a statistic, set beside an official series.
  - name: Board
    description: Questions people have asked about.

paths:
  /records-index.json:
    get:
      tags: [Static files]
      operationId: getRecordsIndex
      summary: List the featured published records
      description: |
        A JSON array with one entry per featured record, with the claim, the
        verdict and the grade as published. Served by GitHub Pages at the site
        root. This is the list the `search_records` tool in `mcp/` reads.

        Observed 2 November 2026: HTTP 200, `application/json; charset=utf-8`,
        `cache-control: max-age=600`, `access-control-allow-origin: *`, 8
        entries, byte for byte equal to `DATOM-website/records-index.json` in
        this repository.
      servers:
        - url: https://datom.science
      responses:
        '200':
          description: The index.
          headers:
            Cache-Control:
              description: Observed value `max-age=600`.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/RecordIndexEntry' }
              example:
                - entryId: D-EC72DA5D
                  plainQuestion: Brain scans (MRI/fMRI) show measurable structural or functional differences in people with eating disorders compared to people without them.
                  href: claim.html?id=D-EC72DA5D
                  verdict: Supported
                  grade: B
                  topic: Clinical medicine
                  sourceCount: 40
                  relatedIds: [D-3F426285, DTM-2026-0703-2E, D-E5E9B06E, D-5CE0BCD1, D-EE363BAC]

  /claims/{id}.json:
    get:
      tags: [Static files]
      operationId: getClaimFile
      summary: Read one published record
      description: |
        The full published record for one claim: the verdict and grade, the
        evidence split, the scope, the reading guide, the revision history and
        a list of the findings behind it. Served by GitHub Pages.

        Not every record in the index has a file. The GLP-1 record
        (`DTM-2026-0703-2E`) is listed in the index with `href` `record.html`
        and has no claims file: this route answers 404 for it, and for any other
        id with no file. The 404 body is an HTML page, not JSON.

        Observed 2 November 2026: `claims/D-EC72DA5D.json` answered HTTP 200,
        `application/json; charset=utf-8`, 40,444 bytes, byte for byte equal to
        `DATOM-website/claims/D-EC72DA5D.json`. `claims/DTM-2026-0703-2E.json`
        and `claims/D-NOPE.json` answered HTTP 404 with `text/html`. All seven
        files in `DATOM-website/claims/` have the same top level keys.
      servers:
        - url: https://datom.science
      parameters:
        - name: id
          in: path
          required: true
          description: A record id from `records-index.json` (`entryId`).
          schema: { type: string, pattern: '^[A-Za-z0-9-]+$' }
          examples:
            supported:
              value: D-EC72DA5D
      responses:
        '200':
          description: The record.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClaimFile' }
        '404':
          description: No claims file with that id. The body is an HTML page.
          content:
            text/html:
              schema: { type: string }

  /api/record/{id}:
    get:
      tags: [Records]
      operationId: getApiRecord
      summary: Read a public record by id
      description: |
        One route, four kinds of id. The kind is chosen by the shape of the id:

        | Id | What comes back |
        | --- | --- |
        | `q-` and 16 lowercase hex characters | `PendingRecordView`: a question and where it stands |
        | `RR-` and 8 uppercase hex characters | `ReadinessRecord`: a published Readiness Record |
        | `RR-index` | `ReadinessIndex`: the public Readiness Records, newest first, at most 100 |
        | `DA-` and 16 uppercase hex characters | `DemandAnalysis`: a published full analysis |

        Any other id, including the `D-` ids of the featured records, answers
        404. Use `/claims/{id}.json` for those.

        A record that is not public answers 404, the same as an id that does
        not exist.

        Observed 2 November 2026: `q-b24b2850abe6a9ce`, `q-7eb713e6e178739b`,
        `q-9de6e74ffb23ac70` answered 200; `RR-A5CF43A3` and 9 other Readiness
        Records answered 200; `RR-index` answered 200 with 100 entries;
        `D-EC72DA5D`, `q-0000000000000000`, `RR-00000000`, `RR-NOPE0000`,
        `DA-0000000000000000` and `xyz` answered 404. No `DA-` record was published, so the
        `DemandAnalysis` schema is taken from the service and was not seen in a
        live response.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          examples:
            question:
              value: q-b24b2850abe6a9ce
            readiness:
              value: RR-A5CF43A3
            readinessIndex:
              value: RR-index
        - name: pdf
          in: query
          required: false
          description: |
            Only meaningful for a `DA-` id. The value `1` returns a signed URL
            for the report PDF instead of the record. Taken from the service;
            not seen in a live response.
          schema: { type: string, enum: ['1'] }
      responses:
        '200':
          description: The record, in the shape that matches the id.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/PendingRecordView'
                  - $ref: '#/components/schemas/ReadinessRecord'
                  - $ref: '#/components/schemas/ReadinessIndex'
                  - $ref: '#/components/schemas/DemandAnalysis'
                  - $ref: '#/components/schemas/PdfLink'
              examples:
                question:
                  summary: A question that has been published (id q-b24b2850abe6a9ce)
                  value:
                    canonicalId: q-b24b2850abe6a9ce
                    canonicalText: Does NAD+ improve cognitive function in healthy adults?
                    ladderStep: PUBLISHED
                    statusLabel: ''
                    statusCopy: []
                    confirmedRequestCount: 0
                    publishedHref: readiness.html?id=RR-A5CF43A3
        '404':
          description: No public record with that id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: No record found for that id }
        '500':
          description: The record could not be loaded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: Could not load the record }

  /api/figure/{id}:
    get:
      tags: [Figure Records]
      operationId: getFigureRecord
      summary: Read a public Figure Record
      description: |
        A Figure Record sets a statistic that a claim states beside the
        official series it was compared with, and carries a closed outcome word.
        It states no verdict word, no grade and no confidence score.

        The route serves public records only. It answers 404 when no record
        has been written for the id yet, which is the normal answer for a check
        still running, and 404 for a private record.

        Observed 2 November 2026: `F-0000000000000000` answered HTTP 404
        `{"error":"No figure record for that id"}`; `nonsense` answered HTTP 400
        `{"error":"That is not a figure record id"}`. **No public Figure Record
        existed to read, so the 200 shape below was not seen in a live
        response.** It is the record the service returns, as defined by the
        Figure Record schema.
      parameters:
        - name: id
          in: path
          required: true
          description: '`F-` followed by 16 lowercase hexadecimal characters.'
          schema: { type: string, pattern: '^F-[0-9a-f]{16}$' }
      responses:
        '200':
          description: The latest version of the public record.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FigureRecord' }
        '400':
          description: The id is not shaped like a Figure Record id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: That is not a figure record id }
        '404':
          description: No public Figure Record with that id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: No figure record for that id }
        '500':
          description: The record could not be loaded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: Could not load the figure record }

  /api/board:
    get:
      tags: [Board]
      operationId: getBoard
      summary: List the questions on the public board
      description: |
        The questions people have asked about that are listed publicly, from
        the `mostRequested-index` of the `PendingRecords` table, highest
        `confirmedRequestCount` first according to the service. All three rows
        returned on 2 November 2026 had a count of 0, so the order was not
        visible in the live response.

        Observed 2 November 2026: HTTP 200 with 3 rows; `limit=2` returned 2
        rows. A `limit` that is not a whole number from 1 to 100 is refused
        with 400 (before the elmer-api deploy carrying this, `limit=0`
        answered 500 and text was ignored).
      parameters:
        - name: limit
          in: query
          required: false
          description: Most rows to return, a whole number from 1 to 100. The default is 50.
          schema: { type: integer, minimum: 1, maximum: 100 }
      responses:
        '200':
          description: The rows, as an array.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/BoardRow' }
              example:
                - canonicalId: q-b24b2850abe6a9ce
                  canonicalText: Does NAD+ improve cognitive function in healthy adults?
                  status: published
                  confirmedRequestCount: 0
                  sponsoredBy: ''
                  createdAt: '2026-08-05T00:46:18.689Z'
        '400':
          description: The limit is not a whole number from 1 to 100.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500':
          description: The board could not be loaded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: Could not load the board }

components:
  schemas:
    Error:
      type: object
      description: The body of every error the API returns from these routes.
      required: [error]
      properties:
        error: { type: string }

    # ── static files ────────────────────────────────────────────────────────

    RecordIndexEntry:
      type: object
      description: One entry of `records-index.json`. Every field was present on all 8 entries observed.
      required: [entryId, plainQuestion, href, verdict, grade, topic, sourceCount, relatedIds]
      properties:
        entryId:
          type: string
          description: The record id. Seven are `D-` and 8 hex characters; the GLP-1 record is `DTM-2026-0703-2E`.
        plainQuestion:
          type: string
          description: The claim, in plain words.
        href:
          type: string
          description: Path of the record's page, relative to https://datom.science/. Observed `claim.html?id=D-...` and `record.html`.
        verdict:
          type: string
          description: |
            The verdict as published. The public words are Supported,
            Contested, Unsupported and Insufficient record; the index held
            Supported, Contested and Insufficient record on the day it was read.
          examples: [Supported, Contested, Insufficient record]
        grade:
          type: string
          description: The evidence grade as published, a letter from A to F. Observed A, B, C and D.
          examples: [A, B, C, D]
        topic:
          type: string
          description: A topic label, for example `Clinical medicine`.
        sourceCount:
          type: integer
          description: The source count the site prints for the record.
        relatedIds:
          type: array
          description: Ids of related records, as `entryId` values. Five entries on every record observed.
          items: { type: string }

    ClaimFile:
      type: object
      description: |
        One `claims/{id}.json` file. Every key listed here was present in all
        seven files. Text fields are written for a lay reader.
      required:
        - entryId
        - name
        - anchorClaim
        - plainQuestion
        - confidenceSummary
        - as_of
        - verdict
        - grade
        - gradeLabel
        - decisive
        - totalDatomCount
        - isHealthDomain
        - guide
        - evidenceSplit
        - figures
        - datoms
        - scope
        - metric
        - revisions
      properties:
        entryId: { type: string }
        name: { type: string, description: A short title for the record. }
        anchorClaim: { type: string, description: The claim in a longer wording than `plainQuestion`. }
        plainQuestion: { type: string, description: The same claim in plain words. Equals `plainQuestion` in the index. }
        confidenceSummary: { type: string }
        as_of: { type: string, description: 'A date as text, not an ISO date. Observed `12 Jul 2026` in all seven files, while the revisions in the same files are dated later.' }
        verdict:
          type: string
          description: The verdict as published. Equals `verdict` in the index for every record observed.
        grade:
          type: string
          description: The grade as published. Equals `grade` in the index for every record observed.
        gradeLabel: { type: string, description: 'Words for the grade, for example `Solid evidence base`.' }
        decisive:
          type: object
          description: Findings that directly tested the claim.
          required: [supports, contradicts]
          properties:
            supports: { type: integer }
            contradicts: { type: integer }
        totalDatomCount:
          type: integer
          description: The number of findings (datoms) linked to the record. The `datoms` array lists at most 50 of them in the files observed.
        isHealthDomain: { type: boolean }
        guide:
          type: object
          required: [whatScienceSays, howConfident, decisionSupport, faultLines]
          properties:
            whatScienceSays: { type: string }
            howConfident: { type: string }
            decisionSupport:
              type: array
              items: { type: string }
            faultLines: { type: string }
        evidenceSplit:
          type: object
          description: All linked findings, by relationship to the claim.
          required: [supports, contradicts, conditional]
          properties:
            supports: { type: integer }
            contradicts: { type: integer }
            conditional: { type: integer }
        figures:
          type: object
          required: [methodologyMix, sourceTypeMix, publicationYears]
          properties:
            methodologyMix:
              type: array
              items: { $ref: '#/components/schemas/LabelCount' }
            sourceTypeMix:
              type: array
              items: { $ref: '#/components/schemas/LabelCount' }
            publicationYears:
              type: array
              items:
                type: object
                required: [year, count]
                properties:
                  year: { type: integer }
                  count: { type: integer }
        datoms:
          type: array
          description: Findings behind the record. 50 entries in five files, 45 in one and 4 in one.
          items: { $ref: '#/components/schemas/ClaimFinding' }
        scope: { type: string, description: Who or what the claim is about. }
        metric: { type: string, description: What is measured. }
        revisions:
          type: array
          description: Recorded changes to the verdict or grade. One entry in each file observed.
          items: { $ref: '#/components/schemas/ClaimRevision' }

    LabelCount:
      type: object
      required: [label, count]
      properties:
        label: { type: string }
        count: { type: integer }

    ClaimFinding:
      type: object
      required: [claim, source, relationship]
      properties:
        claim: { type: string, description: What this finding states. }
        source: { type: string, description: The citation as text, usually ending in a DOI. }
        year:
          type: integer
          description: Publication year. The key is absent on one finding in one file (`D-3F426285`, 11th entry of `datoms`).
        relationship:
          type: string
          description: How the finding bears on the claim. Observed SUPPORTS, CONTRADICTS and CONDITIONAL.
          examples: [SUPPORTS, CONTRADICTS, CONDITIONAL]

    ClaimRevision:
      type: object
      description: A recorded rescore. Verdict values here are upper case (`SUPPORTED`), unlike the published `verdict`.
      required: [date, engineVersion, fromVerdict, toVerdict, fromGrade, toGrade, reason]
      properties:
        date: { type: string, format: date-time }
        engineVersion: { type: string, examples: [v2.2026.09] }
        fromVerdict: { type: string }
        toVerdict: { type: string }
        fromGrade: { type: string }
        toGrade: { type: string }
        reason: { type: string }

    # ── GET /api/board ──────────────────────────────────────────────────────

    BoardRow:
      type: object
      description: One row of the board. Every field was present on all three rows observed.
      required: [canonicalId, canonicalText, status, confirmedRequestCount, sponsoredBy, createdAt]
      properties:
        canonicalId: { type: string, pattern: '^q-[0-9a-f]{16}$' }
        canonicalText: { type: string, description: The question as DATOM stored it. }
        status:
          type: string
          enum: [queued, in_production, published]
        confirmedRequestCount:
          type: integer
          description: People who confirmed by email that they want to be told the answer.
        sponsoredBy: { type: string, description: 'Empty string when nobody sponsors the question.' }
        createdAt: { type: string, format: date-time }

    # ── GET /api/record/{id} ────────────────────────────────────────────────

    PendingRecordView:
      type: object
      description: |
        Returned for a `q-` id. Observed on three published questions. For a
        published question `statusLabel` is the empty string and `statusCopy`
        is empty; for a question that is not yet published the service fills
        both with fixed status lines.
      required: [canonicalId, canonicalText, ladderStep, statusLabel, statusCopy, confirmedRequestCount]
      properties:
        canonicalId: { type: string, pattern: '^q-[0-9a-f]{16}$' }
        canonicalText: { type: string }
        ladderStep:
          type: string
          enum: [OPEN, IN_VERIFICATION, PUBLISHED]
        statusLabel: { type: string }
        statusCopy:
          type: array
          items: { type: string }
        confirmedRequestCount: { type: integer }
        publishedHref:
          type: string
          description: Present only when `ladderStep` is `PUBLISHED`. A path on https://datom.science/, for example `readiness.html?id=RR-A5CF43A3`.

    ReadinessIndex:
      type: object
      description: Returned for `RR-index`. Observed with 100 entries, which is the most the service returns.
      required: [entries]
      properties:
        entries:
          type: array
          items: { $ref: '#/components/schemas/ReadinessIndexEntry' }

    ReadinessIndexEntry:
      type: object
      required: [recordId, claimStatement, domain, d0Passed, headline, layHeadline, boundaryRung, publishedAt]
      properties:
        recordId: { type: string, pattern: '^RR-[0-9A-F]{8}$' }
        claimStatement: { type: string }
        domain: { type: string }
        d0Passed:
          type: boolean
          description: Whether the claim is stated precisely enough to test.
        headline: { type: string, examples: ['Supported through D1. D2 Untested.'] }
        layHeadline: { type: string }
        boundaryRung:
          type: [string, 'null']
          description: The highest rung D1 to D6 reached with every lower rung supported. Observed `D1`.
        publishedAt: { type: string, format: date-time }

    ReadinessRecord:
      type: object
      description: |
        Returned for an `RR-` id. A Readiness Record states how far a claim
        has got on a ladder of steps, D0 to D6. It is not a Confidence Report
        and carries no verdict or grade. Observed on 10 records; every key
        below was present on all 10. Objects marked open carry more keys than
        are listed.
      required:
        - recordId
        - origin
        - pendingCanonicalId
        - askedAs
        - readinessScore
        - readinessStep
        - fullAnalysis
        - followers
        - version
        - claimStatement
        - claimDirection
        - domain
        - d0Passed
        - readings
        - boundary
        - layHeadline
        - laySummary
        - bibliography
        - spec
        - originator
        - domainParams
        - ladderVersion
        - contentHash
        - publishedAt
        - versions
      properties:
        recordId: { type: string, pattern: '^RR-[0-9A-F]{8}$' }
        origin:
          type: string
          enum: [demand, harvest]
          description: '`demand` when the record grew from a question someone asked; `harvest` otherwise.'
        pendingCanonicalId:
          type: [string, 'null']
          description: The `q-` id of the question the record grew from, or null.
        askedAs:
          type: [string, 'null']
          description: The reader's own wording, when the record came from a question and the claim was restated. Otherwise null.
        readinessScore:
          type: integer
          description: 0 to 100. The boundary rung as a share of the ladder. It is not a confidence score.
        readinessStep:
          type: object
          required: [reached, of]
          properties:
            reached: { type: integer }
            of: { type: integer }
        fullAnalysis:
          type: object
          description: Open. Whether a full analysis is linked.
          required: [available]
          properties:
            available: { type: boolean }
            requestHref:
              type: string
              description: Present when `available` is false. A path on https://datom.science/.
            href:
              type: string
              description: Present when `available` is true (taken from the service; not seen live).
            verdictLabel:
              type: [string, 'null']
              description: Present when `available` is true (taken from the service; not seen live).
            gradeAbsolute:
              type: [string, 'null']
              description: Present when `available` is true (taken from the service; not seen live).
            confidencePct:
              type: number
              description: Present only for a linked featured record (taken from the service; not seen live).
        followers:
          type: object
          required: [confirmedRequestCount, askedBeforePublication]
          properties:
            confirmedRequestCount: { type: integer }
            askedBeforePublication: { type: boolean }
        version: { type: integer }
        claimStatement: { type: string }
        claimDirection:
          type: string
          description: Observed `increases`, `decreases`, `no_change` and `other`.
        domain:
          type: string
          description: Free text. Observed `therapeutic`, `other`, `social-measurement` and `chemical/ingredient`.
        d0Passed: { type: boolean }
        readings:
          type: array
          description: One reading for each rung D1 to D6, in the 10 records observed.
          items:
            type: object
            required: [rung, status, rationale, evidenceDois, rungName, statusLabel]
            properties:
              rung: { type: string, enum: [D1, D2, D3, D4, D5, D6] }
              status:
                type: string
                enum: [SUPPORTED, CONTESTED, UNSUPPORTED, UNTESTED, UNSPECIFIED]
              rationale: { type: string }
              evidenceDois:
                type: array
                items: { type: string }
              rungName: { type: string, description: 'For example `Specified` for D1.' }
              statusLabel: { type: string, description: 'For example `Supported` for SUPPORTED.' }
        boundary:
          type: object
          required: [kind, boundaryRung, nextRung, nextStatus, headline]
          properties:
            kind:
              type: string
              enum: [d0_unspecified, boundary_at, no_support]
              description: Only `boundary_at` was observed.
            boundaryRung: { type: [string, 'null'] }
            nextRung: { type: [string, 'null'] }
            nextStatus: { type: [string, 'null'] }
            headline: { type: string }
        layHeadline: { type: string }
        laySummary: { type: string }
        bibliography:
          type: array
          items:
            type: object
            required: [title, year, doi]
            properties:
              title: { type: string }
              year: { type: [integer, 'null'] }
              doi: { type: string }
        spec:
          type: object
          description: How the claim was made testable.
          required: [nullCondition, domain, observable, whatMustBePinned, specifiable, threshold, conditions]
          properties:
            nullCondition: { type: string }
            domain: { type: string }
            observable: { type: string }
            whatMustBePinned: { type: string }
            specifiable: { type: boolean }
            threshold: { type: string }
            conditions: { type: string }
        originator:
          type: object
          description: The earliest source found for the claim, if any.
          required: [unresolved, clusterId]
          properties:
            unresolved: { type: boolean }
            clusterId:
              type: [string, 'null']
              description: A DOI when resolved, null otherwise.
            note:
              type: string
              description: Present only when `unresolved` is true. Observed `originator unresolved`.
        domainParams:
          type: object
          description: The words and labels the record was published with.
          required: [domain, ladderVersion, lexicon, phase2DisplayName, phase2Labels]
          properties:
            domain: { type: string }
            ladderVersion: { type: string }
            phase2DisplayName:
              type: [string, 'null']
              description: Null on the same records where `phase2Labels` is null (3 of the 10 observed).
            phase2Labels:
              type: [object, 'null']
              description: Labels for steps T1 to T6. Null on 3 of the 10 records observed.
              additionalProperties: { type: string }
            lexicon:
              type: object
              description: The lines the public page prints, so a reader can quote the record's own words.
              required: [rungLines, d0PassedLine, confidenceDistinction, phase2BlankLine, statusLines]
              properties:
                rungLines:
                  type: object
                  description: One line for each rung D0 to D6.
                  additionalProperties: { type: string }
                d0PassedLine: { type: string }
                confidenceDistinction: { type: string }
                phase2BlankLine: { type: string }
                statusLines:
                  type: object
                  description: One line for each status word.
                  additionalProperties: { type: string }
        ladderVersion: { type: string, examples: [v1.2026.09] }
        contentHash:
          type: string
          description: A 64 character hexadecimal hash of the record's content.
        publishedAt: { type: string, format: date-time }
        versions:
          type: array
          items:
            type: object
            required: [version, publishedAt, contentHash]
            properties:
              version: { type: integer }
              publishedAt: { type: string, format: date-time }
              contentHash: { type: string }

    DemandAnalysis:
      type: object
      description: |
        Returned for a `DA-` id once a full analysis has been approved and
        published. TAKEN FROM THE SERVICE CODE; no analysis was published on 2 November
        2026, so this shape was not seen in a live response. Text fields are
        `null` when empty.
      required:
        - recordId
        - kind
        - claim
        - askedAs
        - verdict
        - verdictLabel
        - grade
        - receipt
        - whatTheScienceSays
        - confidenceNarrative
        - decisionSupportBullets
        - generatedAt
        - publishedAt
        - readinessRecordUrl
        - followers
        - hasPdf
      properties:
        recordId: { type: string, pattern: '^DA-[0-9A-F]{16}$' }
        kind: { type: string, enum: [demand_analysis] }
        claim: { type: string }
        askedAs: { type: string }
        verdict: { type: [string, 'null'] }
        verdictLabel: { type: [string, 'null'] }
        grade: { type: [string, 'null'] }
        receipt: { type: [string, 'null'] }
        whatTheScienceSays: { type: [string, 'null'] }
        confidenceNarrative: { type: [string, 'null'] }
        decisionSupportBullets:
          type: array
          items: { type: string }
        generatedAt: { type: [string, 'null'] }
        publishedAt: { type: [string, 'null'] }
        readinessRecordUrl: { type: [string, 'null'] }
        followers: { type: integer }
        hasPdf: { type: boolean }

    PdfLink:
      type: object
      description: |
        Returned for `DA-` ids when `pdf=1`. The URL is signed and the service
        sets it to expire after 300 seconds. Taken from the service; not seen
        in a live response. 404 `No PDF for that record` when the analysis has
        no PDF.
      required: [downloadUrl]
      properties:
        downloadUrl: { type: string, format: uri }

    # ── GET /api/figure/{id} ────────────────────────────────────────────────

    FigureRecord:
      type: object
      description: |
        A public Figure Record, latest version. NOT SEEN IN A LIVE RESPONSE:
        this is the Figure Record schema, which the service returns
        unchanged. The schema is strict, so no other keys are
        present. Every number on a record is either the claim's own figure or
        comes from the agency's response; the model chooses labels only, and
        `provenance` says where it was used.
      required:
        - figureId
        - laneVersion
        - visibility
        - ownerAccountId
        - claimAsStated
        - figureSpec
        - series
        - official
        - comparison
        - outcome
        - notCheckableReason
        - notCheckableDetail
        - reasons
        - siblingsNoted
        - provenance
        - lexicon
        - createdAt
        - createdBy
        - version
        - integrity
      properties:
        figureId: { type: string, pattern: '^F-[0-9a-f]{16}$' }
        laneVersion: { type: string }
        visibility:
          type: string
          enum: [public]
          description: The route serves `public` only (the schema also allows `private`, which is never returned here).
        ownerAccountId:
          type: 'null'
          description: Always null on a public record.
        claimAsStated:
          type: string
          maxLength: 500
        figureSpec: { $ref: '#/components/schemas/FigureSpec' }
        series:
          oneOf:
            - $ref: '#/components/schemas/FigureSeries'
            - type: 'null'
          description: The official series used. Null when none was found.
        official:
          oneOf:
            - $ref: '#/components/schemas/FigureOfficial'
            - type: 'null'
          description: What the series shows, with its vintage and request URL. Null when no series was read.
        comparison:
          oneOf:
            - $ref: '#/components/schemas/FigureComparison'
            - type: 'null'
        outcome:
          type: string
          enum: [ACCURATE, OVERSTATED, UNDERSTATED, OPPOSITE, MISFRAMED, NOT_CHECKABLE]
          description: |
            The closed outcome word. These six words are the only ones. None is
            a verdict word of the Confidence Report or of a Readiness Record.
        notCheckableReason:
          type: [string, 'null']
          enum:
            - PERIOD_UNRESOLVED
            - PERIOD_OUTSIDE_SERIES
            - NO_SERIES
            - PROVIDER_UNAVAILABLE
            - PROVIDER_NO_KEY
            - UNIT_MISMATCH
            - COMPARISON_UNSUPPORTED
            - null
          description: Set exactly when `outcome` is NOT_CHECKABLE.
        notCheckableDetail: { type: [string, 'null'] }
        reasons:
          type: array
          description: Machine reasons, a closed set. Lower case codes are reasons for an outcome; upper case are the not checkable reasons above.
          items: { type: string }
        siblingsNoted:
          type: array
          description: Related series or periods that were noted for a misframed claim.
          items: { $ref: '#/components/schemas/FigureSibling' }
        provenance: { $ref: '#/components/schemas/FigureProvenance' }
        lexicon:
          type: object
          description: The lines a page prints for each outcome, stored on the record so a reader quotes the record's own words and not the code of the day.
          required: [version, status, outcomeLines, notCheckableLines]
          properties:
            version: { type: string }
            status: { type: string, description: 'Whether the wording is final. ' }
            outcomeLines:
              type: object
              additionalProperties: { type: string }
            notCheckableLines:
              type: object
              additionalProperties: { type: string }
        createdAt: { type: string, pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$' }
        createdBy:
          type: string
          enum: [public-box, pipeline, check-worker, report]
          description: What produced the record.
        version: { type: integer, minimum: 1 }
        supersedes:
          type: integer
          minimum: 1
          description: The version this one replaces. Absent for version 1.
        integrity:
          type: object
          description: Records are append only and hash chained. `recordHash` covers the body and `prevHash`.
          required: [recordHash, prevHash, chainIndex]
          properties:
            recordHash: { type: string, pattern: '^[0-9a-f]{64}$' }
            prevHash:
              type: string
              description: The previous version's `recordHash`, or `genesis` for the first version.
            chainIndex: { type: integer, minimum: 1 }

    FigureSpec:
      type: object
      description: The figure the claim states, as read from the claim.
      required: [measure, statedValue, statedUnit, comparison, periodStated, periodUsed, periodSource, placeStated, placeUsed]
      properties:
        measure: { type: string }
        statedValue: { type: number }
        statedScale: { type: string, enum: [thousand, million, billion, trillion] }
        statedUnit: { type: string, enum: [percent, points, count, currency, rate, ratio, other] }
        comparison: { type: string, enum: [level, change, ratio, rank] }
        direction: { type: string, enum: [increase, decrease, none] }
        periodStated:
          type: [object, 'null']
          required: [text]
          properties:
            from: { type: string }
            to: { type: string }
            text: { type: string }
        periodUsed:
          type: [object, 'null']
          required: [from, to]
          properties:
            from: { type: [string, 'null'] }
            to: { type: string }
        periodSource:
          type: [string, 'null']
          enum: [stated, default_latest, default_latest_vs_year_ago, default_latest_vs_reference, null]
        placeStated: { type: [string, 'null'] }
        placeUsed: { type: [string, 'null'] }

    FigureSeriesInfo:
      type: object
      required: [seriesKey, measure, unit, frequency, valueKind, unitClass, unitScale, tolerance]
      properties:
        seriesKey: { type: string }
        measure: { type: string }
        unit: { type: string }
        frequency: { type: string, enum: [weekly, monthly, quarterly, annual] }
        valueKind: { type: string, enum: [level, index, growth_rate] }
        unitClass: { type: string, enum: [percent, currency, count, index, other] }
        unitScale: { type: number, exclusiveMinimum: 0 }
        tolerance:
          type: object
          required: [levelAbs, changeRel]
          properties:
            levelAbs: { type: number, exclusiveMinimum: 0 }
            changeRel: { type: number, exclusiveMinimum: 0, maximum: 1 }

    FigureSeries:
      description: The official series and who publishes it.
      allOf:
        - $ref: '#/components/schemas/FigureSeriesInfo'
        - type: object
          required: [provider, providerSeriesId, definition, definitionSource]
          properties:
            provider:
              type: string
              enum: [BLS, FRED, CENSUS, FBI_CDE, CDC, WORLD_BANK, OWID, EUROSTAT, BEA]
            providerSeriesId: { type: string }
            definition: { type: string }
            definitionSource: { type: string, format: uri }

    FigureObservation:
      type: object
      required: [period, value]
      properties:
        period: { type: string }
        value: { type: number }

    FigureOfficialBlock:
      type: object
      required: [mode, valueFrom, valueTo, changePct, periodFrom, periodTo, figure, window]
      properties:
        mode: { type: string, enum: [level, pct_change, abs_change, growth_value] }
        valueFrom: { type: [number, 'null'] }
        valueTo: { type: number }
        changePct: { type: [number, 'null'] }
        periodFrom: { type: [string, 'null'] }
        periodTo: { type: string }
        figure: { type: number }
        window:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/FigureObservation' }

    FigureOfficial:
      description: The official figure, with when and where it was read.
      allOf:
        - $ref: '#/components/schemas/FigureOfficialBlock'
        - type: object
          required: [vintage, vintageSource, requestUrl, retrievedAt]
          properties:
            vintage:
              type: string
              description: The date of the data release, `YYYY-MM-DD` at the start.
            vintageSource: { type: string, enum: [provider, fetchedAt] }
            requestUrl:
              type: string
              format: uri
              description: The agency request the figure came from.
            retrievedAt: { type: string, pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$' }

    FigureComparison:
      type: object
      required: [stated, official, differencePoints, toleranceApplied, withinTolerance]
      properties:
        stated: { type: number }
        official: { type: number }
        differencePoints: { type: number }
        toleranceApplied:
          type: object
          required: [rounding, absolute]
          properties:
            levelAbs: { type: number }
            changeRel: { type: number }
            rounding: { type: number }
            absolute: { type: number }
        withinTolerance: { type: boolean }
        misframed:
          type: object
          description: Present when the claim matches a sibling series or another period and not the main one.
          required: [kind, seriesKey, measure, periodFrom, periodTo, figure]
          properties:
            kind: { type: string, enum: [sibling, other_period] }
            seriesKey: { type: string }
            measure: { type: string }
            periodFrom: { type: [string, 'null'] }
            periodTo: { type: string }
            figure: { type: number }

    FigureSibling:
      type: object
      required: [series, periodFrom, periodTo, official, comparison, outcome]
      properties:
        series: { $ref: '#/components/schemas/FigureSeriesInfo' }
        periodFrom: { type: [string, 'null'] }
        periodTo: { type: string }
        official: { $ref: '#/components/schemas/FigureOfficialBlock' }
        comparison:
          type: object
          description: Same fields as `FigureComparison` without `misframed`.
          required: [stated, official, differencePoints, toleranceApplied, withinTolerance]
          properties:
            stated: { type: number }
            official: { type: number }
            differencePoints: { type: number }
            toleranceApplied:
              type: object
              required: [rounding, absolute]
              properties:
                levelAbs: { type: number }
                changeRel: { type: number }
                rounding: { type: number }
                absolute: { type: number }
            withinTolerance: { type: boolean }
        outcome: { type: string, enum: [ACCURATE, OVERSTATED, UNDERSTATED, OPPOSITE] }

    FigureCallProvenance:
      type: object
      description: One model call. `costCents` is the metered cost.
      required: [model, promptHash, costCents, cached]
      properties:
        model: { type: string }
        promptHash: { type: string, pattern: '^[0-9a-f]{64}$' }
        costCents: { type: number, minimum: 0 }
        cached: { type: boolean }

    FigureProvenance:
      type: object
      required: [kindRead, seriesResolution, fallbackProvider]
      properties:
        kindRead:
          type: object
          description: How the claim was judged to state a figure.
          required: [via, reason, call]
          properties:
            via: { type: string, enum: [rule, model] }
            reason: { type: string }
            call:
              oneOf:
                - $ref: '#/components/schemas/FigureCallProvenance'
                - type: 'null'
        seriesResolution:
          type: object
          description: How the series was found.
          required: [via, shortlist, pick, embed, detail]
          properties:
            via:
              type: [string, 'null']
              enum: [lexical, embedding_pick, tie_pick, null]
            shortlist:
              type: array
              items: { type: string }
            pick:
              oneOf:
                - $ref: '#/components/schemas/FigureCallProvenance'
                - type: 'null'
            embed:
              oneOf:
                - $ref: '#/components/schemas/FigureCallProvenance'
                - type: 'null'
            detail: { type: [string, 'null'] }
        fallbackProvider: { type: [string, 'null'] }
