openapi: 3.1.0
info:
  title: StructaMed Partner API
  version: "1.0.0"
  summary: Rule-based interpretation of structured laboratory results.
  description: |
    Send structured laboratory results, receive a rule-based interpretation. The analysis is
    deterministic and rule-based: no AI model is called, and nothing you send is used for any
    purpose other than producing the response.

    This is an interpretation aid for clinicians and laboratory staff. It does not replace
    clinical judgement, and it makes no diagnosis.

    Narrative documentation and the onboarding steps for your IT team are sent with your key.

    **Limits.** 300 results per request, 12 previous panels, 1 MB body. 30 requests per minute per key
    (counted per server instance). One successful (HTTP 200) call to `/api/v1/analyze` or
    `/api/v1/report` counts as one report; failed calls are not counted. A monthly report allowance
    applies only if one is set on your key.
  contact:
    email: support@structamed.com
servers:
  - url: https://{host}
    description: Production host given to you with your key.
    variables:
      host:
        default: structamed.com
security:
  - bearerAuth: []
  - apiKeyAuth: []
tags:
  - name: Analysis
  - name: Report
  - name: Onboarding
  - name: Reference
paths:
  /api/v1/analyze:
    post:
      tags: [Analysis]
      operationId: analyze
      summary: Interpret a panel of laboratory results (JSON)
      description: |
        Maps your rows to markers (a supported LOINC code decides the marker, otherwise the name), converts
        units, grades each value against your range (or the engine's default), and returns findings,
        patterns, notes, next steps, trends against `previous`, and everything it could not use in
        `unrecognized`. Nothing is dropped silently.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AnalyzeRequest"
            examples:
              visit2:
                summary: Demo patient, second visit with the first as previous (abridged)
                value:
                  patient: { sex: male, birth_date: "1978-06-10" }
                  collected_at: "2026-10-01"
                  mode: clinical
                  lang: en
                  results:
                    - { name: Total Cholesterol, value: 5.5, unit: mmol/L, ref_high: 5.2, flag: H }
                    - { name: LDL Cholesterol (calculated), value: 3.64, unit: mmol/L, ref_high: 3.4, flag: H }
                    - { name: HDL Cholesterol, value: 1.18, unit: mmol/L, ref_low: 1, flag: N }
                    - { name: Apolipoprotein B, value: 1.12, unit: g/L, ref_low: 0.6, ref_high: 1, flag: H }
                    - { name: Lipoprotein(a), value: 215, unit: nmol/L, ref_high: 75, flag: H }
                  previous:
                    - collected_at: "2025-10-01"
                      results:
                        - { name: Total Cholesterol, value: 6.3, unit: mmol/L }
                        - { name: Apolipoprotein B, value: 1.32, unit: g/L }
              withContext:
                summary: Patient context, LOINC codes and a word result
                value:
                  patient:
                    sex: female
                    age: 52
                    context:
                      menopausal_status: post
                      medications: [levothyroxine]
                      conditions: [hypothyroidism]
                  results:
                    - { name: TSH, code: "3016-3", value: 3.1, unit: mIU/L, ref_low: 0.4, ref_high: 4.0 }
                    - { name: Anti-CCP, value: Negative }
              omics:
                summary: Beta. Blood, functional, genetic, epigenetic and microbiome data in one call (synthetic values)
                value: {
                    "patient": {
                      "sex": "female",
                      "age": 56
                    },
                    "collected_at": "2026-08-18",
                    "mode": "clinical",
                    "lang": "en",
                    "results": [
                      {
                        "name": "HbA1c",
                        "value": 5.5,
                        "unit": "%",
                        "ref_high": 5.7
                      },
                      {
                        "name": "Fasting Glucose",
                        "value": 5.9,
                        "unit": "mmol/L",
                        "ref_low": 3.9,
                        "ref_high": 5.5
                      },
                      {
                        "name": "Vitamin B12",
                        "value": 410,
                        "unit": "pmol/L",
                        "ref_low": 150,
                        "ref_high": 700
                      }
                    ],
                    "omics": {
                      "functional": [
                        {
                          "panel": "Cytokines",
                          "specimen": "serum",
                          "name": "Interleukin-6",
                          "value": 14,
                          "unit": "pg/mL",
                          "ref_text": "<9.0",
                          "flag": "H"
                        },
                        {
                          "panel": "Hormones, dried urine",
                          "specimen": "dried urine",
                          "name": "Cortisol, Morning",
                          "value": 63,
                          "unit": "ug/gCR",
                          "ref_text": "10.00-45.00",
                          "flag": "H",
                          "time_point": "morning"
                        },
                        {
                          "panel": "Environmental toxins",
                          "specimen": "urine",
                          "name": "Bisphenol A (BPA)",
                          "value": "<DL",
                          "unit": "ug/gCR",
                          "ref_text": "<4.00"
                        }
                      ],
                      "genotypes": [
                        {
                          "gene": "MTHFR",
                          "rsid": "rs1801133",
                          "genotype": "heterozygous",
                          "source_test": "Methylation SNP panel"
                        },
                        {
                          "gene": "TCF7L2",
                          "rsid": "rs7903146",
                          "genotype": "CT",
                          "source_test": "Metabolic SNP panel"
                        },
                        {
                          "gene": "APOE",
                          "genotype": "e3/e4",
                          "source_test": "Metabolic SNP panel"
                        }
                      ],
                      "epigenetic": {
                        "chronological_age": 56,
                        "clocks": [
                          {
                            "name": "Clock A",
                            "value": 52,
                            "percentile": 12.8
                          },
                          {
                            "name": "Clock B",
                            "value": 43.8,
                            "percentile": 18.4
                          }
                        ],
                        "organ_ages": [
                          {
                            "system": "Liver",
                            "age": 52.6
                          },
                          {
                            "system": "Hormone",
                            "age": 60.5
                          }
                        ]
                      },
                      "microbiome": {
                        "scores": [
                          {
                            "key": "digestion",
                            "value": 10,
                            "scale_max": 10
                          },
                          {
                            "key": "imidazole_propionate",
                            "value": 2,
                            "scale_max": 10
                          },
                          {
                            "key": "serotonin",
                            "value": 5,
                            "scale_max": 10
                          }
                        ]
                      },
                      "notes": [
                        "Fasting sample, no supplements for 48 hours"
                      ]
                    }
                  }
      responses:
        "200":
          description: The interpretation.
          headers:
            x-request-id:
              $ref: "#/components/headers/RequestId"
            cache-control:
              description: Always `no-store`.
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnalyzeResponse"
              examples:
                abridged:
                  summary: Abridged response for the second-visit request
                  value:
                    request_id: req_0123456789abcdef0123
                    engine_version: structamed-engine/2026-10
                    lang: en
                    summary: Some results are outside the reference range and worth reviewing.
                    summary_code: summary.warning_findings_present
                    risk: { score: 5.1, level: medium }
                    counts: { received: 42, graded: 42, abnormal: 6 }
                    findings:
                      - key: total_cholesterol
                        name: Total Cholesterol
                        input_name: Total Cholesterol
                        matched_by: name
                        value: 5.5
                        unit: mmol/L
                        ref_low: null
                        ref_high: 5.2
                        ref_source: partner
                        status: high
                        severity: info
                        rationale:
                          - code: rationale.aboveCeiling
                            text: At 5.5 mmol/L it sits above the upper limit of 5.2.
                            values: { value: 5.5, ceiling: 5.2, unit: mmol/L }
                    patterns: []
                    clusters: [atherogenic, metabolic]
                    notes:
                      - { key: notes.homa_ir_normal, text: HOMA-IR within normal range. }
                    next_steps:
                      urgency: routine
                      steps:
                        - category: assess
                          key: nextSteps.category.assessAtherogenicRisk
                          text: "ApoB / LDL-C above range: assess global cardiovascular risk."
                          confidence: moderate
                    unrecognized: []
                    trends:
                      - key: total_cholesterol
                        name: Total Cholesterol
                        unit: mmol/L
                        points:
                          - { collected_at: "2025-10-01", value: 6.3 }
                          - { collected_at: "2026-10-01", value: 5.5 }
                        direction: down
                        quality: improving
                        first: 6.3
                        last: 5.5
                        change_percent: -13
                        notable_change: false
                        out_of_range: closer
                omics:
                  summary: Beta. Trimmed response to the omics request, with the integrated block
                  value: {
                        "request_id": "req_0123456789abcdef0123",
                        "engine_version": "structamed-engine/2026-10",
                        "lang": "en",
                        "counts": {
                          "received": 3,
                          "graded": 3,
                          "abnormal": 1
                        },
                        "findings": "[ ... blood findings as in /v1/analyze ... ]",
                        "integrated": {
                          "version": "omics-layer/2026-10",
                          "domains_present": [
                            "blood",
                            "functional",
                            "genetic",
                            "epigenetic",
                            "microbiome"
                          ],
                          "ages": {
                            "chronological": 56,
                            "items": [
                              {
                                "label": "Clock A",
                                "value": 52,
                                "delta": -4,
                                "source": "epigenetic"
                              },
                              {
                                "label": "Clock B",
                                "value": 43.8,
                                "delta": -12.2,
                                "source": "epigenetic"
                              }
                            ]
                          },
                          "functional": [
                            {
                              "key": "cytokine_il6",
                              "name": "Interleukin-6",
                              "panel": "Cytokines",
                              "value": "14",
                              "unit": "pg/mL",
                              "status": "high",
                              "ref": "<9.0"
                            },
                            {
                              "key": "urine_free_cortisol_morning",
                              "name": "Cortisol, Morning",
                              "panel": "Hormones, dried urine",
                              "value": "63",
                              "unit": "ug/gCR",
                              "status": "high",
                              "ref": "10.00-45.00"
                            },
                            {
                              "key": "urine_bpa",
                              "name": "Bisphenol A (BPA)",
                              "panel": "Environmental toxins",
                              "value": "<DL",
                              "unit": "ug/gCR",
                              "status": "below_detection",
                              "ref": "<4.00"
                            }
                          ],
                          "findings": [
                            {
                              "id": "G1",
                              "system": "Glucose & insulin",
                              "priority": "moderate",
                              "title": "Discordant glycaemia: fasting glucose raised, HbA1c below 5.7%",
                              "text": "Glucose (fasting) 5.9 mmol/L is at or above the ADA flag of 5.6 mmol/L, while HbA1c 5.5 % is below the 5.7% prediabetes threshold. The two measures reflect different physiology and often disagree. (text trimmed)",
                              "evidence": "B",
                              "provenance": [
                                {
                                  "domain": "blood",
                                  "test": "Blood panel",
                                  "name": "Glucose (fasting)",
                                  "value": "5.9 mmol/L",
                                  "flag": "H"
                                },
                                {
                                  "domain": "blood",
                                  "test": "Blood panel",
                                  "name": "HbA1c (Glycated Hemoglobin)",
                                  "value": "5.5 %"
                                }
                              ],
                              "next_steps": [
                                "Repeat fasting glucose",
                                "Consider an oral glucose tolerance test",
                                "Review conditions that lower HbA1c"
                              ]
                            },
                            {
                              "id": "G3",
                              "system": "Glucose & insulin",
                              "priority": "moderate",
                              "title": "TCF7L2 risk genotype with prediabetic markers",
                              "text": "TCF7L2 rs7903146, heterozygous, together with Glucose (fasting) 5.9 mmol/L. The genotype raises the pre-test probability; the measured markers govern. (text trimmed)",
                              "evidence": "B",
                              "provenance": [
                                {
                                  "domain": "blood",
                                  "test": "Blood panel",
                                  "name": "Glucose (fasting)",
                                  "value": "5.9 mmol/L",
                                  "flag": "H"
                                },
                                {
                                  "domain": "genetic",
                                  "test": "Metabolic SNP panel",
                                  "name": "TCF7L2 rs7903146",
                                  "value": "CT"
                                }
                              ],
                              "next_steps": [
                                "Repeat fasting glucose and HbA1c",
                                "Consider an oral glucose tolerance test",
                                "Clinical review of cardiometabolic risk factors"
                              ]
                            }
                          ],
                          "notes": [
                            "Fasting sample, no supplements for 48 hours"
                          ]
                        }
                      }
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/NoRecognizedResults"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
        "502":
          $ref: "#/components/responses/ServerError"
  /api/v1/report:
    post:
      tags: [Report]
      operationId: report
      summary: Branded PDF report
      description: |
        Same authentication, request body, validation, limits and errors as `/api/v1/analyze`, and the
        same rule-based analysis; the response is a PDF in your branding instead of JSON. Two fields are
        added: `patient.display_id` and `design`. `lang` selects the language of the PDF; when absent your
        profile's default is used. One successful call is one metered report; a report call is not also
        counted as an analyze call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReportRequest"
            examples:
              case:
                summary: Report with a case reference
                value:
                  patient: { sex: male, age: 45, display_id: CASE-2026-0417 }
                  design: blocks
                  lang: en
                  results:
                    - { name: ApoB, value: 1.32, unit: g/L, ref_low: 0.6, ref_high: 1.0 }
                    - { name: ALT, value: 22, unit: U/L, ref_high: 50 }
      responses:
        "200":
          description: The PDF.
          headers:
            x-request-id:
              $ref: "#/components/headers/RequestId"
            content-disposition:
              description: '`inline; filename="report-<request_id>.pdf"`'
              schema: { type: string }
            cache-control:
              description: Always `no-store`.
              schema: { type: string }
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "422":
          $ref: "#/components/responses/NoRecognizedResults"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: The analysis failed unexpectedly (`internal_error`) or the PDF could not be rendered (`internal_error`).
          headers:
            x-request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error: { code: internal_error, message: The PDF could not be rendered., request_id: req_0123456789abcdef0123 }
        "502":
          $ref: "#/components/responses/ServerError"
  /api/v1/recognize:
    post:
      tags: [Onboarding]
      operationId: recognize
      summary: Check how many rows are recognised
      description: |
        Onboarding check. Takes the same request body as `/api/v1/analyze` (so you can send a whole export file
        before going live) and returns only how the rows were resolved: no analysis, no AI call, no PDF.

        Same authentication, request validation and per-minute rate limit as analyze. It is **not metered**: no
        usage is recorded and the monthly limit does not apply. Resolution order for every row: your own
        dictionary (agreed with us, no deploy needed) by code, by name with unit, by name; then the LOINC code;
        then the global synonyms. Rows nothing recognised come back with the reason and up to three closest
        catalog entries (`suggestions`). Suggestions are hints for your dictionary and are never applied.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AnalyzeRequest"
      responses:
        "200":
          description: The recognition summary.
          headers:
            x-request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecognizeResponse"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
  /api/v1/loinc:
    get:
      tags: [Reference]
      operationId: loinc
      summary: Supported LOINC codes
      description: |
        The static list of LOINC codes the API routes by (`results[].code`). No key needed. Limited to
        30 requests per minute per client address. The response is cacheable for one hour.
      security: []
      responses:
        "200":
          description: The supported codes.
          headers:
            cache-control:
              description: '`public, max-age=3600`'
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoincResponse"
        "429":
          description: More than 30 requests per minute from this address.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoincRateLimited"
              example:
                error: { code: rate_limited, message: "Too many requests; the limit is 30 per minute." }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "`Authorization: Bearer sm_live_<32 letters or digits>`. Only a hash of the key is stored on our side."
    apiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: The same key, as `x-api-key`. Either header works; a missing, unknown or deactivated key returns 401.
  headers:
    RequestId:
      description: Identifier of this call. Quote it to support. Also present as `request_id` in JSON bodies.
      schema:
        type: string
        pattern: "^req_[0-9a-f]{20}$"
  responses:
    InvalidRequest:
      description: Malformed JSON or a failed validation; `error.message` names the field.
      headers:
        x-request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            notJson:
              value:
                error: { code: invalid_request, message: Request body is not valid JSON., request_id: req_0123456789abcdef0123 }
            field:
              value:
                error: { code: invalid_request, message: 'patient.sex is required and must be "male" or "female".', request_id: req_0123456789abcdef0123 }
            context:
              value:
                error: { code: invalid_request, message: 'patient.context.pregnant applies to patient.sex "female" only.', request_id: req_0123456789abcdef0123 }
    Unauthorized:
      description: Missing, unknown or inactive API key.
      headers:
        x-request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: unauthorized
              message: "Missing, invalid or inactive API key. Send it as 'Authorization: Bearer <key>' or 'x-api-key'."
              request_id: req_0123456789abcdef0123
    PayloadTooLarge:
      description: Body over 1 MB.
      headers:
        x-request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error: { code: payload_too_large, message: Request body is larger than 1 MB., request_id: req_0123456789abcdef0123 }
    NoRecognizedResults:
      description: No row could be matched to a marker or had a usable value. `error.unrecognized` lists the rows with reasons.
      headers:
        x-request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: no_recognized_results
              message: "None of the submitted rows could be used: unrecognized marker names or no result values. See error.unrecognized."
              request_id: req_0123456789abcdef0123
              unrecognized:
                - { name: Xyz, value: 3, unit: u, reason: marker name is not recognized }
    RateLimited:
      description: |
        `rate_limited`: more than 30 requests per minute for your key. `monthly_limit_exceeded`: the monthly
        report allowance set on your key is used up (checked before the work).
      headers:
        x-request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            perMinute:
              value:
                error: { code: rate_limited, message: "Too many requests; the limit is 30 per minute.", request_id: req_0123456789abcdef0123 }
            monthly:
              value:
                error: { code: monthly_limit_exceeded, message: The monthly limit of 500 reports has been reached., request_id: req_0123456789abcdef0123 }
    ServerError:
      description: Unexpected failure (`analysis_failed` 502 or `internal_error` 500). Retry; quote `request_id` if it persists.
      headers:
        x-request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            analysisFailed:
              value:
                error: { code: analysis_failed, message: The analysis engine could not process this request., request_id: req_0123456789abcdef0123 }
            internal:
              value:
                error: { code: internal_error, message: Unexpected error while analyzing., request_id: req_0123456789abcdef0123 }
  schemas:
    Lang:
      type: string
      enum: [en, de, uk, fr, pl, es, pt]
      description: Texts fall back to English where a translation is missing. Marker names are always English.
    Result:
      type: object
      required: [name, value]
      description: |
        One laboratory row. `value` may be a number or a string; `null` or `""` marks a row without a result
        (it is accepted and listed in `unrecognized`, so one blank row never rejects a panel).
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          pattern: "\\S"
          description: The marker name as you label it ("ApoB", "Hemoglobin", "hs-CRP"). Matched by name and synonym.
        code:
          type: [string, "null"]
          maxLength: 200
          description: A LOINC code, for example `1884-6`. A supported code decides the marker; see `GET /api/v1/loinc`.
        value:
          description: 'A number, or a string such as "5.2", "6,1", "5.2 H", "<0.5", "Negative", "1+" (max 100 characters). null or "" is a row without a result.'
          oneOf:
            - type: number
            - type: string
              maxLength: 100
            - type: "null"
        unit:
          type: [string, "null"]
          maxLength: 200
          description: Units are checked and converted to the engine's unit where the conversion is known. Send it.
        ref_low:
          type: [number, "null"]
          description: Your laboratory's lower reference limit. Used instead of the engine default (`ref_source` "partner").
        ref_high:
          type: [number, "null"]
          description: Your upper reference limit. `ref_low` greater than `ref_high` is rejected with 400.
        ref_text:
          type: [string, "null"]
          maxLength: 200
          description: A printed range as text, used only if `ref_low` and `ref_high` are absent.
        flag:
          type: [string, "null"]
          maxLength: 200
          description: "`H`, `HH`, `L`, `LL`, `N` (or high, low, normal). A hint; the value is graded against the range."
        collection:
          $ref: "#/components/schemas/Collection"
    Context:
      type: object
      additionalProperties: false
      description: |
        Optional clinical context. Unknown fields or values and contradictions (pregnancy or menopausal
        status for `male`, a trimester without `pregnant: true`) return 400. The response answers it in
        `context_applied` and `context_ignored`; a field the engine has no rule for is never listed as applied.
      properties:
        athlete:
          description: '`true` is applied as "endurance"; `false` is the default (listed as ignored).'
          oneOf:
            - type: boolean
            - type: string
              enum: [endurance, strength]
            - type: "null"
        menopausal_status:
          type: [string, "null"]
          enum: [pre, peri, post, null]
          description: Women only (400 for `male`). Replaces the age rule.
        pregnant:
          type: [boolean, "null"]
          description: "`true` for `male` returns 400. Not applied under age 18 (listed as ignored)."
        trimester:
          type: [integer, "null"]
          enum: [1, 2, 3, null]
          description: "Requires `pregnant: true`."
        setting:
          type: [string, "null"]
          enum: [outpatient, acute, null]
          description: |
            Clinical setting. `outpatient` replaces emergency-style cardiac next steps with an outpatient work-up
            (unless chest pain or breathlessness is reported) and reads a critical natriuretic peptide as a warning;
            genuinely critical values stay critical. `acute` keeps the emergency-style reading (listed as ignored). When absent, the
            setting from your partner profile (`defaultSetting`) is used if one is agreed; otherwise the engine default applies, which
            gives natriuretic-peptide-only panels (no troponin sent) an outpatient-style cardiac work-up without troponin advice.
        smoker:
          type: [boolean, "null"]
          description: "`true` reads CEA against the smoker limit of 5 ng/mL (non-smokers 3 ng/mL). `false` is the default (listed as ignored)."
        fasting:
          type: [boolean, "null"]
          description: Accepted and ignored; glucose, triglycerides and insulin are always graded against fasting ranges.
        medications:
          type: [array, "null"]
          maxItems: 30
          items:
            type: string
            enum: [statin, levothyroxine, testosterone, biotin, metformin, oral_contraceptive, glp1, clopidogrel, warfarin, allopurinol, carbamazepine, thiopurine, fluoropyrimidine, iron_supplement]
            description: "`statin` is applied (on-treatment low LDL-C, non-HDL-C, ApoB or total cholesterol is not a finding; a CK/ALT/AST note names the statin). `testosterone` and `glp1` are accepted and ignored by the blood engine. `clopidogrel`, `warfarin`, `allopurinol`, `carbamazepine`, `thiopurine`, `fluoropyrimidine` and `iron_supplement` have no blood-engine rule (listed as ignored there); they are read by the multi-omics pharmacogenomic and haematology rules (beta) when `omics.genotypes` / `omics.functional` are sent."
        conditions:
          type: [array, "null"]
          maxItems: 30
          items:
            type: string
            enum: [type2_diabetes, type1_diabetes, diabetes, hypothyroidism, hyperthyroidism, thyroid_disease, ckd, anticoagulation, chronic_liver_disease, gout, heart_failure, acs_pci, ascvd]
        medications_other:
          type: [string, "null"]
          maxLength: 200
          description: Free text about other medicines. Shown in the report, never interpreted (listed in `context_ignored`).
        allergies:
          type: [object, "null"]
          additionalProperties: false
          description: |
            Documented drug allergies. Shown in the report header. A next step whose text names a drug of an allergic class
            carries a caution (`allergy_caution` on the step). Nothing else is interpreted.
          properties:
            drug_classes:
              type: [array, "null"]
              maxItems: 30
              items:
                type: string
                enum: [penicillins, cephalosporins, sulfonamides, nsaids_aspirin, iodinated_contrast, opioids]
            other:
              type: [string, "null"]
              maxLength: 200
              description: Free text, shown and never interpreted.
        surgical_history:
          type: [object, "null"]
          additionalProperties: false
          description: |
            Past surgery, interpreted per procedure (ASMBS, hypoparathyroidism workshop 2022, splenectomy literature):
            a note or next step, a lower grading only for a high platelet, white cell or lymphocyte count after
            `splenectomy` (warning becomes info with `expected_for_condition`; a critical value is never softened),
            and postmenopausal reading of the sex hormones for `oophorectomy_bilateral` (women only, 400 for `male`;
            an explicit `menopausal_status` wins). `cholecystectomy`, `hysterectomy` and `other` are recorded and shown,
            with no laboratory rule.
          properties:
            procedures:
              type: [array, "null"]
              maxItems: 30
              items:
                type: string
                enum: [bariatric_rygb, bariatric_sleeve, bariatric_bpd_ds, gastrectomy_total, gastrectomy_partial, ileal_resection, thyroidectomy, splenectomy, nephrectomy, oophorectomy_bilateral, cholecystectomy, hysterectomy, other]
            other:
              type: [string, "null"]
              maxLength: 200
              description: Free text, shown and never interpreted.
    Patient:
      type: object
      required: [sex]
      description: One of `age` or `birth_date` is required. If both are sent, `age` is used.
      anyOf:
        - required: [age]
        - required: [birth_date]
      properties:
        sex:
          type: string
          enum: [male, female]
          description: Reference ranges and several rules depend on it.
        age:
          type: number
          minimum: 0
          maximum: 120
          description: Years (fractions are floored).
        birth_date:
          type: string
          format: date
          description: YYYY-MM-DD. Age is computed at `collected_at` (today if absent); the result must be 0-120.
        context:
          oneOf:
            - $ref: "#/components/schemas/Context"
            - type: "null"
    PreviousPanel:
      type: object
      required: [collected_at, results]
      properties:
        collected_at:
          type: string
          pattern: "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}(:\\d{2}(\\.\\d{1,3})?)?(Z|[+-]\\d{2}:\\d{2})?)?$"
          description: YYYY-MM-DD or an ISO date-time such as 2026-09-17T08:30, 2026-09-17T08:30:00Z or 2026-09-17T08:30:00+02:00 (calendar validity is checked by the API).
        results:
          type: array
          minItems: 1
          maxItems: 300
          items:
            $ref: "#/components/schemas/Result"
    AnalyzeRequest:
      type: object
      required: [patient]
      # Without `omics` data at least one result is required.
      if:
        not:
          required: [omics]
      then:
        required: [results]
        properties:
          results:
            minItems: 1
      properties:
        patient:
          $ref: "#/components/schemas/Patient"
        collected_at:
          type: string
          pattern: "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}(:\\d{2}(\\.\\d{1,3})?)?(Z|[+-]\\d{2}:\\d{2})?)?$"
          description: |
            YYYY-MM-DD or an ISO date-time (2026-09-17T08:30, ...T08:30:00Z, ...T08:30:00+02:00). The date is used
            for age and for dating the trend. A time lets the engine state the interval between serial samples
            (troponin); the interval is only known when both stamps carry a time and both carry an offset or both
            do not, otherwise the response says it is unknown.
        mode:
          type: string
          enum: [clinical, wellness]
          default: clinical
          description: Wellness uses optimal-range framing and wellness next steps.
        lang:
          $ref: "#/components/schemas/Lang"
        results:
          type: array
          maxItems: 300
          description: At least one result is required unless `omics` carries data (then the array may be empty or left out).
          items:
            $ref: "#/components/schemas/Result"
        omics:
          $ref: "#/components/schemas/Omics"
        previous:
          type: [array, "null"]
          maxItems: 12
          description: Earlier panels, used for `trends`.
          items:
            $ref: "#/components/schemas/PreviousPanel"
    Omics:
      type: object
      description: |
        Beta. Functional, genetic, epigenetic and microbiome data sent alongside (or instead of) blood results.
        The response then carries an `integrated` block. `/v1/report` still needs at least one blood result.
      additionalProperties: false
      properties:
        functional:
          type: array
          maxItems: 600
          items:
            type: object
            required: [name, value]
            properties:
              panel: { type: string, maxLength: 120 }
              specimen: { type: string, maxLength: 60 }
              name: { type: string, minLength: 1, maxLength: 160 }
              code: { type: string, maxLength: 60 }
              value:
                description: A number or a short string such as "<DL", "<0.01" or "ND".
                oneOf:
                  - { type: number }
                  - { type: string, minLength: 1, maxLength: 32 }
              unit: { type: string, maxLength: 60 }
              ref_low: { type: number }
              ref_high: { type: number }
              ref_text: { type: string, maxLength: 200 }
              flag: { type: string, maxLength: 40 }
              time_point: { type: string, maxLength: 60 }
              collection:
                $ref: "#/components/schemas/Collection"
        genotypes:
          type: array
          maxItems: 100
          items:
            type: object
            required: [gene]
            description: |
              `gene` plus at least one of `genotype`, `diplotype`, `hgvs`, `phenotype`, `classification` (beta, clinical genetics).
              Pharmacogenomic rows (CYP2C19, CYP2C9, SLCO1B1, CYP2D6, CYP3A5, TPMT, NUDT15, DPYD, VKORC1, HLA-B*58:01, HLA-B*15:02) are
              read as a CPIC diplotype (`*1/*2`) or phenotype; haemoglobinopathy rows (HBA1/HBA2 e.g. `--SEA/αα`, HBB, HbE), G6PD,
              familial-hypercholesterolaemia genes (LDLR, APOB, PCSK9) and hereditary-cancer genes (BRCA1/2, PALB2, ATM, CHEK2,
              MLH1, MSH2, MSH6, PMS2, EPCAM, TP53, APC, MUTYH) are read from `classification`, `hgvs` and the call text.
            anyOf:
              - { required: [genotype] }
              - { required: [diplotype] }
              - { required: [hgvs] }
              - { required: [phenotype] }
              - { required: [classification] }
            properties:
              gene: { type: string, minLength: 1, maxLength: 40 }
              rsid: { type: string, maxLength: 40 }
              genotype: { type: string, minLength: 1, maxLength: 40 }
              call: { type: string, maxLength: 120 }
              source_test: { type: string, maxLength: 120 }
              classification: { type: string, enum: [pathogenic, likely_pathogenic, vus, likely_benign, benign], description: Laboratory classification of a monogenic variant (ACMG/AMP 2015). }
              hgvs: { type: string, maxLength: 120 }
              diplotype: { type: string, maxLength: 60, description: 'Star-allele diplotype such as "*1/*2".' }
              phenotype: { type: string, maxLength: 120, description: 'The laboratory''s predicted phenotype, such as "intermediate metabolizer".' }
        epigenetic:
          type: object
          description: Ages are 0-150, percentiles 0-100, lists up to 100 entries.
          properties:
            chronological_age: { type: number, minimum: 0, maximum: 150 }
            clocks:
              type: array
              maxItems: 100
              items:
                type: object
                required: [name, value]
                properties:
                  name: { type: string }
                  value: { type: number, minimum: 0, maximum: 150 }
                  percentile: { type: number, minimum: 0, maximum: 100 }
            pace:
              type: object
              required: [name, value]
              properties:
                name: { type: string }
                value: { type: number }
            telomere:
              type: object
              properties:
                length: { type: number }
                biological_age: { type: number, minimum: 0, maximum: 150 }
            organ_ages:
              type: array
              maxItems: 100
              items:
                type: object
                required: [system, age]
                properties:
                  system: { type: string }
                  age: { type: number, minimum: 0, maximum: 150 }
            immune:
              type: array
              maxItems: 100
              items:
                type: object
                required: [cell]
                properties:
                  cell: { type: string }
                  percentile: { type: number, minimum: 0, maximum: 100 }
                  status: { type: string }
            dnam_markers:
              type: array
              maxItems: 100
              items:
                type: object
                required: [name]
                properties:
                  name: { type: string }
                  percentile: { type: number, minimum: 0, maximum: 100 }
                  status: { type: string }
        notes:
          type: array
          maxItems: 5
          description: Free-text notes about the samples (at most 5, each at most 300 characters, no control characters); echoed in `integrated.notes`.
          items: { type: string, minLength: 1, maxLength: 300 }
        microbiome:
          type: object
          properties:
            microbiome_age: { type: number, minimum: 0, maximum: 150 }
            diversity: { type: string }
            fb_ratio: { type: string }
            balance: { type: string }
            scores:
              type: array
              maxItems: 100
              items:
                type: object
                required: [key, value]
                properties:
                  key: { type: string }
                  value: { type: number }
                  scale_max: { type: number, exclusiveMinimum: 0 }
            risk_positions:
              type: array
              maxItems: 100
              items:
                type: object
                required: [condition, position]
                properties:
                  condition: { type: string }
                  position: { type: string }
    Integrated:
      type: object
      description: Beta. Present only when the request carried `omics` data.
      required: [version, domains_present, functional, findings, notes]
      properties:
        version: { type: string, example: omics-layer/2026-10 }
        domains_present:
          type: array
          items: { type: string, enum: [blood, functional, genetic, epigenetic, microbiome] }
        ages:
          type: object
          required: [chronological, items]
          properties:
            chronological: { type: number }
            items:
              type: array
              items:
                type: object
                required: [label, value, delta, source]
                properties:
                  label: { type: string }
                  value: { type: number }
                  delta: { type: number }
                  source: { type: string }
        functional:
          type: array
          description: Functional rows passed through, with their position against the printed range.
          items:
            type: object
            required: [name, value, status]
            properties:
              key: { type: string }
              name: { type: string }
              panel: { type: string }
              value: { type: string }
              unit: { type: string }
              status: { type: string, enum: [low, normal, high, below_detection, unknown] }
              ref: { type: string }
        findings:
          type: array
          description: Cross-domain findings (empty until the rule layer ships).
          items:
            type: object
            required: [id, system, priority, title, text, evidence, provenance, next_steps]
            properties:
              id: { type: string }
              system: { type: string }
              priority: { type: string, enum: [high, moderate, info] }
              title: { type: string, description: "Beta. In the request `lang` (en, de, uk, fr, pl, es, pt); `id`, `system`, `provenance` and units stay language-neutral." }
              text: { type: string }
              evidence: { type: string, enum: [A, B, C] }
              provenance:
                type: array
                items:
                  type: object
                  required: [domain, test, name, value]
                  properties:
                    domain: { type: string, enum: [blood, functional, genetic, epigenetic, microbiome, context], description: "`context` is a chip for a `patient.context` field (a medication, menopausal status, pregnancy) that qualified the wording; it is never an independent result." }
                    test: { type: string }
                    name: { type: string }
                    value: { type: string }
                    flag: { type: string }
              next_steps:
                type: array
                items: { type: string }
        notes:
          type: array
          items: { type: string }
    ReportRequest:
      description: The analyze request plus the report-only fields.
      allOf:
        - $ref: "#/components/schemas/AnalyzeRequest"
        - type: object
          properties:
            patient:
              type: object
              properties:
                display_id:
                  type: [string, "null"]
                  maxLength: 64
                  pattern: "^[^\\u0000-\\u001f\\u007f]*$"
                  description: A reference printed on the PDF (a case number). No control characters. Names are not required.
            design:
              type: [string, "null"]
              enum: [blocks, classic, null]
              description: Absent means your profile's default (`blocks`).
            report_options:
              type: [object, "null"]
              description: (Beta) Layout options of the PDF. Unknown fields are rejected.
              properties:
                appendix:
                  type: [string, "null"]
                  enum: [compact, full, null]
                  description: "`compact` (default) lists only the results outside range or of interest in the omics appendix and counts the rest; `full` lists every result."
    Rationale:
      type: object
      required: [code, text]
      properties:
        code: { type: string }
        text:
          type: [string, "null"]
        values:
          type: object
    Finding:
      type: object
      required: [key, name, value, unit, ref_low, ref_high, ref_source, status, severity, rationale]
      description: One entry per graded marker, normal ones included.
      properties:
        key:
          type: string
          description: Engine marker key.
        name:
          type: string
          description: English marker name.
        input_name:
          type: string
          description: Your own label (absent for derived values).
        matched_by:
          type: string
          enum: [loinc, name, partner_dictionary]
        derived:
          type: boolean
          description: "`true` for a value the engine calculated (for example HOMA-IR), not one you sent."
        value:
          description: The value in the engine's unit.
          oneOf:
            - type: number
            - type: string
            - type: "null"
        value_label:
          type: string
          description: For word results ("Negative").
        operator:
          type: string
          description: For limit values ("<0.5").
        unit:
          type: string
        ref_low:
          type: [number, "null"]
        ref_high:
          type: [number, "null"]
        ref_source:
          type: string
          enum: [partner, engine_default]
        status:
          type: string
          enum: [low, normal, high]
          description: Position against the range used.
        severity:
          type: string
          enum: [normal, info, warning, critical]
          description: How much attention the finding deserves; not the same as `status`.
        submitted:
          type: object
          description: Your original value, unit and range when a unit conversion happened.
          properties:
            value: {}
            unit: { type: string }
            ref_low: { type: [number, "null"] }
            ref_high: { type: [number, "null"] }
        expected_for_condition:
          description: Set when a stated condition explains a softened warning. Critical findings are never softened.
        rationale:
          type: array
          items:
            $ref: "#/components/schemas/Rationale"
    Pattern:
      type: object
      required: [type, confidence, evidence]
      properties:
        type: { type: string }
        confidence: { type: string }
        evidence:
          type: array
          items: { type: string }
    Note:
      type: object
      required: [key, text]
      properties:
        key: { type: string }
        text: { type: string }
    NextStep:
      type: object
      required: [category, key, text, confidence]
      properties:
        category:
          type: string
          enum: [review, evaluate, correlate, monitor, assess, ruleOut, schedule, repeat, consider, urgent]
        key: { type: string }
        text:
          type: [string, "null"]
        confidence:
          type: string
          enum: [high, moderate]
        already_measured:
          type: array
          items: { type: string }
        allergy_caution:
          type: array
          description: Drug-allergy classes from `patient.context.allergies` whose drugs this step's text names.
          items:
            type: string
            enum: [penicillins, cephalosporins, sulfonamides, nsaids_aspirin, iodinated_contrast, opioids]
    Unrecognized:
      type: object
      required: [name, value, reason]
      description: A row the engine could not use, with the reason. Review this list.
      properties:
        name: { type: string }
        value:
          oneOf:
            - type: number
            - type: string
        unit: { type: string }
        reason: { type: string }
    Trend:
      type: object
      required: [key, points, direction, quality, first, last, change_percent, notable_change]
      description: Per marker present in the current panel and at least one earlier panel with the same unit. Values are your own numbers in your unit.
      properties:
        key: { type: string }
        name: { type: string }
        unit: { type: string }
        points:
          type: array
          description: Oldest first, the current panel last.
          items:
            type: object
            required: [collected_at, value]
            properties:
              collected_at:
                type: [string, "null"]
              value: { type: number }
        direction:
          type: string
          enum: [up, down, flat]
          description: Flat is under 5 % movement.
        quality:
          type: string
          enum: [improving, worsening, mixed, stable]
          description: Judged by distance to the range, not by the sign of the change.
        first: { type: number }
        last: { type: number }
        change_percent:
          type: [number, "null"]
        notable_change:
          type: boolean
          description: "`true` when the value moved by 25 % or more between the first and last point; never changes `quality`."
        out_of_range:
          type: string
          enum: [further, closer]
    ContextApplied:
      type: object
      required: [field, value, effect, affected_markers]
      description: What changed the analysis. `affected_markers` are the markers of THIS panel the rule can act on; empty means nothing in the panel is subject to it. (Beta) Entries with `field` `omics.genotypes.mthfr`, `omics.genotypes.apoe` or `omics.genotypes.fut2` show a genotype from `omics.genotypes` that was passed to the blood engine as genetic context; they appear even when `patient.context` was not sent.
      properties:
        field: { type: string }
        value: {}
        effect: { type: string }
        affected_markers:
          type: array
          items: { type: string }
    ContextIgnored:
      type: object
      required: [field, value, reason]
      description: Accepted, but the engine has no rule for it (or it did not apply), with the reason.
      properties:
        field: { type: string }
        value: {}
        reason: { type: string }
    Collection:
      type: object
      additionalProperties: false
      description: |
        Explicit collection context of one row. What you send here wins over what the engine would infer from the
        printed name or the report's notes. `provoked: false` sent explicitly overrides any provocation wording
        (a urine metal is then graded against the unprovoked range). `provoking_agent` without `provoked` means
        a provoked collection. Provoked urine metals are shown without a status and feed no rule. `specimen` and
        `time_point` help to pick the right catalog marker (for example the morning or evening free cortisol).
        `fasting` and `collected_at` are carried with the row and not graded on yet.
      properties:
        specimen: { type: string, maxLength: 60, description: 'For example "urine", "serum", "saliva".' }
        time_point: { type: string, maxLength: 60, description: 'For example "morning", "evening", "waking +30 min".' }
        fasting: { type: boolean }
        provoked: { type: boolean, description: A chelating agent was given before the collection. }
        provoking_agent: { type: string, maxLength: 60, description: '"DMSA", "DMPS", "EDTA" or free text.' }
        collected_at: { type: string, description: A date (YYYY-MM-DD) or an ISO date-time. }
    Recognition:
      type: object
      required: [total_rows, by_source, no_value, unresolved]
      description: How every submitted row (results and omics.functional) was resolved. Each row is counted once.
      properties:
        total_rows: { type: integer }
        by_source:
          type: object
          required: [partner_dictionary, code, global, none]
          description: Rows by the stage that resolved them. `none` is the number of unresolved rows.
          properties:
            partner_dictionary: { type: integer, description: Resolved by your own dictionary. }
            code: { type: integer, description: Resolved by a LOINC code. }
            global: { type: integer, description: Resolved by the global names and synonyms. }
            none: { type: integer }
        no_value: { type: integer, description: 'Rows without a result value ("Pending", "N/A", blank): nothing to resolve.' }
        unresolved:
          type: array
          items:
            type: object
            required: [name, reason, suggestions]
            properties:
              name: { type: string }
              code: { type: string }
              unit: { type: string }
              reason: { type: string }
              suggestions:
                type: array
                maxItems: 3
                description: The closest catalog entries. Hints only; never applied.
                items:
                  type: object
                  required: [domain, key, label]
                  properties:
                    domain: { type: string, enum: [results, functional] }
                    key: { type: string }
                    label: { type: string }
    RecognizeResponse:
      type: object
      required: [request_id, recognition]
      properties:
        request_id:
          type: string
          pattern: "^req_[0-9a-f]{20}$"
        recognition:
          $ref: "#/components/schemas/Recognition"
        warnings:
          type: array
          description: Unit and range notes from the mapping step.
          items: { type: string }
        notes:
          type: array
          description: Input-check notes about the omics block (units, flags, provocation, duplicates).
          items: { type: string }
    AnalyzeResponse:
      type: object
      required: [request_id, engine_version, lang, summary, summary_code, risk, counts, findings, patterns, clusters, notes, next_steps, unrecognized]
      properties:
        request_id:
          type: string
          pattern: "^req_[0-9a-f]{20}$"
        engine_version:
          type: string
          example: structamed-engine/2026-10
        lang:
          $ref: "#/components/schemas/Lang"
        summary: { type: string }
        summary_code: { type: string }
        risk:
          type: object
          required: [score, level]
          description: A coarse overall level with a numeric score; useful for sorting, not for diagnosis.
          properties:
            score: { type: [number, "null"] }
            level:
              type: [string, "null"]
              enum: [low, medium, high, null]
        counts:
          type: object
          required: [received, graded, abnormal]
          properties:
            received: { type: integer, minimum: 0 }
            graded: { type: integer, minimum: 0 }
            abnormal: { type: integer, minimum: 0 }
        findings:
          type: array
          items:
            $ref: "#/components/schemas/Finding"
        patterns:
          type: array
          description: Combinations of markers that suggest a recognized picture; suggestions for review, not diagnoses.
          items:
            $ref: "#/components/schemas/Pattern"
        clusters:
          type: array
          description: Body systems the findings belong to.
          items: { type: string }
        notes:
          type: array
          items:
            $ref: "#/components/schemas/Note"
        next_steps:
          type: object
          required: [urgency, steps]
          properties:
            urgency:
              type: string
              enum: [routine, soon, urgent]
            steps:
              type: array
              items:
                $ref: "#/components/schemas/NextStep"
        unrecognized:
          type: array
          items:
            $ref: "#/components/schemas/Unrecognized"
        context_applied:
          type: array
          description: Present only when `patient.context` was sent.
          items:
            $ref: "#/components/schemas/ContextApplied"
        context_ignored:
          type: array
          description: Present only when `patient.context` was sent.
          items:
            $ref: "#/components/schemas/ContextIgnored"
        warnings:
          type: array
          description: Plain-text notes, present only when there is something to say (a LOINC code and name that disagree, a reference range that does not fit the unit, a molar default range).
          items: { type: string }
        trends:
          type: array
          description: Present only when `previous` was sent.
          items:
            $ref: "#/components/schemas/Trend"
        integrated:
          $ref: "#/components/schemas/Integrated"
        recognition:
          description: "Present when it carries news (at least one row nothing recognised, or one row your dictionary resolved). `POST /api/v1/recognize` always returns it."
          $ref: "#/components/schemas/Recognition"
        trends_skipped:
          type: array
          description: Present only when `previous` was sent and a marker could not be compared (unit differs between panels; never converted silently).
          items:
            type: object
            required: [key, reason]
            properties:
              key: { type: string }
              reason: { type: string }
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              enum: [invalid_request, unauthorized, payload_too_large, no_recognized_results, rate_limited, monthly_limit_exceeded, analysis_failed, internal_error]
            message: { type: string }
            request_id:
              type: string
              pattern: "^req_[0-9a-f]{20}$"
            unrecognized:
              type: array
              description: Only with `no_recognized_results`.
              items:
                $ref: "#/components/schemas/Unrecognized"
    LoincResponse:
      type: object
      required: [verified, count, codes]
      properties:
        verified:
          type: string
          description: Verification date of the list.
        count: { type: integer, minimum: 0 }
        codes:
          type: array
          items:
            type: object
            required: [code, key, specimen, typical_unit, loinc_name]
            properties:
              code: { type: string }
              key:
                type: string
                description: Engine marker key.
              specimen: { type: string }
              typical_unit: { type: string }
              loinc_name: { type: string }
    LoincRateLimited:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [rate_limited]
            message: { type: string }
