openapi: 3.1.0

# =============================================================================
# Integrated Laboratory Intelligence Platform — Platform API
#
# RFP No. ASLM/ACDC/DEP/CAP/LIP/08/31/26
# Prepared by Omevision (Eneza Eswatini (Pty) Ltd) for Annex 4.
#
# WHAT THIS DOCUMENT IS
#   The complete interface contract for the platform proposed in this bid:
#   the read API through which the Diagnostic Network Optimisation, Sample
#   Referral Optimisation and Hub-and-Spoke tools, national dashboards and
#   Member State systems consume laboratory intelligence, and the write
#   surface through which authorised source systems deliver data.
#
#   It is written to the same standard Omevision applies to its production
#   APIs: specification first, specification as the contract tested in CI,
#   OAuth 2.0 / OIDC with scoped tokens, cursor pagination, RFC 9457 problem
#   details, idempotent writes, explicit deprecation policy.
#
# HOW IT IS USED IN DELIVERY
#   This file is the baseline for Deliverable 3 (Dashboard Specification).
#   It is refined with the Technical Working Group, versioned in the
#   ASLM-owned repository from Day 1, and served as live documentation from
#   /docs on every environment.
# =============================================================================

info:
  title: Integrated Laboratory Intelligence Platform API
  version: 1.0.0
  summary: Laboratory capacity, equipment, test menu, biosafety, access and diagnostic performance intelligence for Ebola and VHF preparedness and response.
  description: |
    # Overview

    The Platform API exposes the governed indicator store of the Integrated
    Laboratory Intelligence Platform. It is the single documented interface
    for every downstream consumer. No client, tool or partner system reads
    the platform database directly (RFP Section 6.5, condition 5).

    ## Resource model

    | Resource | What it represents |
    |---|---|
    | `facilities` | Laboratories and health facilities, reconciled to the national Master Facility List, carrying LabMap, DHIS2 and LIS identifiers |
    | `equipment` | Diagnostic instruments held by a facility, with functionality status and rated throughput |
    | `test-menu` | Tests offered by a facility, coded to LOINC where a mapping exists |
    | `indicators` | The indicator catalogue: definition, numerator, denominator, disaggregations, source, frequency |
    | `indicator-values` | Computed values by indicator × geography or facility × period, with quality flags and provenance |
    | `geo-units` | The administrative hierarchy, with geometry and population |
    | `geo/isochrones` | Travel-time catchments around facilities, for population-access analysis |
    | `geo/referral-paths` | Road distance and travel time between an origin geography and candidate referral laboratories |
    | `sources` | Catalogued source systems, their interface pattern and interoperability maturity |
    | `loads` | Ingestion runs, with row counts, checksums and outcome |
    | `submissions` | Structured data collected through ONA forms, and its validation state |
    | `ask` | Natural-language question answered as governed SQL over the indicator store |

    ## Authentication

    All endpoints require a bearer token issued by the platform identity
    provider (Keycloak, OIDC).

    * **System clients** (DNO tool, referral optimiser, national dashboard,
      partner system) use the `client_credentials` grant.
    * **Human users** use the `authorization_code` grant with PKCE.
    * **System-to-system exchange with the Africa CDC Central Data
      Repository** additionally requires mutual TLS where the CDR requires it.

    Tokens carry both **scopes** (what may be done) and a **data scope**
    (which organisation-unit subtree and which sensitivity tier may be seen).
    The data scope is enforced server-side on every query. A token cannot
    widen its own geography by changing a query parameter; requests outside
    the token's subtree return `403 out_of_scope`, never a filtered-down
    success.

    ## Conventions

    * **Versioning** — the major version is in the path (`/v1`). Breaking
      changes go to `/v2`. A version is supported for at least 24 months
      after its successor ships; the `Sunset` and `Deprecation` headers
      (RFC 8594) are returned throughout the notice period.
    * **Pagination** — cursor-based. Responses carry `data` and `page`;
      follow `page.next_cursor` until it is null. `page_size` maximum 500,
      default 100. Offset pagination is not supported: the indicator store
      restates periods when late data arrives, and offsets skip rows when it does.
    * **Periods** — ISO 8601. Days `2026-09-01`, weeks `2026-W36`,
      months `2026-09`, quarters `2026-Q3`, years `2026`. Ranges use
      `period_start` and `period_end` inclusive.
    * **Freshness** — every payload carrying data derived from a source system
      includes `provenance`, with the source, the load that produced it and
      the observation date. Consumers are expected to display it. A number
      without its freshness has caused more bad decisions in outbreak response
      than a missing number has.
    * **Missing data** — absent facts are absent. The API never returns `0`
      for "not reported": `value` is `null` and `quality_flags` contains
      `no_data`. Consumers must distinguish the two.
    * **Errors** — RFC 9457 `application/problem+json`, with a stable
      machine-readable `type` and a `trace_id` that matches the platform logs.
    * **Idempotency** — every write accepts an `Idempotency-Key` header.
      Replaying a key within 24 hours returns the original result rather than
      applying the change twice. Ingestion clients on unreliable connectivity
      are expected to use it.
    * **Rate limiting** — per client credential. Headers `RateLimit-Limit`,
      `RateLimit-Remaining`, `RateLimit-Reset` (RFC 9239 draft conventions)
      on every response; `429` carries `Retry-After`.
    * **Languages** — `Accept-Language: fr` or `en` selects the language of
      indicator names, definitions and error titles. French is the default
      for Member States that set it as their platform default.
    * **Bandwidth** — all responses support `gzip` and `br`. Endpoints that
      can return large collections accept `fields` to project only the
      attributes needed. A 1 Mbps link is the design assumption, not the
      exception.

  contact:
    name: Omevision — Platform Engineering
    url: https://omevision.com
  license:
    name: Property of ASLM, Africa CDC and participating Member States (RFP Section 9.0)
    identifier: LicenseRef-ASLM-AfricaCDC-MemberStates

servers:
  - url: https://{host}/api/v1
    description: Member State production instance
    variables:
      host:
        default: lip.moh.example.gov
        description: National instance hostname, set per Member State deployment
  - url: https://lip-staging.{host}/api/v1
    description: Staging (pre-production mirror)
    variables:
      host:
        default: moh.example.gov
  - url: https://lip.africacdc.example.org/api/v1
    description: Continental instance — DSA-permitted aggregates only

tags:
  - name: Facilities
    description: Laboratory register, reconciled to the Master Facility List
  - name: Equipment
    description: Diagnostic instruments, functionality and utilisation
  - name: Test menu
    description: Tests offered, coded to LOINC where mapped
  - name: Indicators
    description: The indicator catalogue and its computed values
  - name: Geography
    description: Administrative hierarchy, isochrones and referral paths
  - name: Interoperability
    description: Source systems, ingestion loads and reconciliation
  - name: Collection
    description: ONA form submissions and their validation state
  - name: Ask
    description: Natural-language query over the governed indicator store
  - name: Operations
    description: Health, readiness and metadata

security:
  - oauth2: []

paths:

  # ---------------------------------------------------------------- Facilities
  /facilities:
    get:
      tags: [Facilities]
      operationId: listFacilities
      summary: List laboratories and health facilities
      description: |
        Returns facilities within the caller's data scope. Results are
        reconciled to the national Master Facility List; each record carries
        the source-system identifiers it was matched from and the confidence
        of that match, so a consumer can decide whether to trust a linkage.
      parameters:
        - $ref: '#/components/parameters/GeoUnit'
        - $ref: '#/components/parameters/Tier'
        - $ref: '#/components/parameters/Sector'
        - name: has_test
          in: query
          description: Restrict to facilities offering this test (LOINC code or national test code).
          schema: { type: string, examples: ['94531-1'] }
        - name: vhf_ready
          in: query
          description: Restrict to facilities meeting the agreed VHF-readiness criteria.
          schema: { type: boolean }
        - name: updated_since
          in: query
          description: Only facilities whose record changed after this instant.
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Fields'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of facilities.
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Facility' }
                  page: { $ref: '#/components/schemas/Page' }
              examples:
                page:
                  value:
                    data:
                      - mfl_id: "CM-CE-0142"
                        name: "Centre Pasteur du Cameroun — Yaoundé"
                        tier: "national_reference"
                        sector: "public"
                        geo_unit_id: "CM-CE-MFO-01"
                        coordinates: { latitude: 3.8721, longitude: 11.5169 }
                        capability_score: 78
                        vhf_ready: true
                        identifiers:
                          labmap_id: "LM-CMR-000142"
                          dhis2_org_unit: "kJq2mPyFEHo"
                          lis_code: "CPC-YDE"
                        identifier_match:
                          method: "deterministic"
                          confidence: 1.0
                        provenance:
                          source: "labmap"
                          load_id: "ld_01J9Z2K7Q0"
                          observed_at: "2026-08-30"
                    page: { next_cursor: "eyJpZCI6IkNNLUNFLTAxNDIifQ", page_size: 100 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
      security:
        - oauth2: [facilities:read]

  /facilities/{mfl_id}:
    parameters:
      - name: mfl_id
        in: path
        required: true
        description: Master Facility List identifier.
        schema: { type: string }
    get:
      tags: [Facilities]
      operationId: getFacility
      summary: Retrieve one facility
      description: |
        Returns the full facility profile: capability sub-scores, readiness
        flags, equipment summary, test-menu summary and human resources by
        cadre. Composite scores are returned with the weights used, so the
        consumer can reproduce the number rather than trust it.
      responses:
        '200':
          description: The facility.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FacilityDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
      security:
        - oauth2: [facilities:read]

  /facilities/{mfl_id}/equipment:
    parameters:
      - name: mfl_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Equipment]
      operationId: listFacilityEquipment
      summary: List diagnostic instruments held by a facility
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [functional, down, decommissioned, unknown] }
        - name: instrument_type
          in: query
          schema: { type: string, examples: ['rt_pcr', 'genexpert', 'sequencer'] }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of instruments.
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Equipment' }
                  page: { $ref: '#/components/schemas/Page' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
      security:
        - oauth2: [equipment:read]

  /facilities/{mfl_id}/test-menu:
    parameters:
      - name: mfl_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Test menu]
      operationId: listFacilityTestMenu
      summary: List tests offered by a facility
      description: |
        Tests are returned with their national code and, where a mapping
        exists, the LOINC code. Unmapped tests are returned with
        `loinc_code: null` rather than omitted — the gap is information.
      parameters:
        - name: disease_programme
          in: query
          schema: { type: string, examples: ['vhf', 'tb', 'hiv', 'amr'] }
        - name: status
          in: query
          schema: { type: string, enum: [offered, suspended, withdrawn] }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of test-menu items.
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TestMenuItem' }
                  page: { $ref: '#/components/schemas/Page' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
      security:
        - oauth2: [testmenu:read]

  # ---------------------------------------------------------------- Indicators
  /indicators:
    get:
      tags: [Indicators]
      operationId: listIndicators
      summary: List the indicator catalogue
      description: |
        The catalogue is data, not code. Every indicator carries its
        definition, numerator, denominator, permitted disaggregations,
        source system, update frequency and the quality rules applied to it.
        Composite indicators additionally publish their component weights and
        the date the Technical Working Group approved them.
      parameters:
        - name: domain
          in: query
          schema:
            type: string
            enum: [laboratory_capacity, molecular_equipment, test_menu, biosafety, population_access, diagnostic_performance, interoperability, executive]
        - name: q
          in: query
          description: Free-text search across name, synonyms and definition, in the requested language.
          schema: { type: string }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of indicator definitions.
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Indicator' }
                  page: { $ref: '#/components/schemas/Page' }
              examples:
                pa01:
                  value:
                    data:
                      - id: "PA-01"
                        domain: "population_access"
                        name: "Population within 2 hours' travel of a molecular laboratory"
                        definition: "Population living within a 120-minute road travel-time isochrone of a laboratory with at least one functional molecular platform."
                        numerator: "Population inside the isochrone"
                        denominator: "Total population of the geography"
                        unit: "percent"
                        disaggregations: [province, district, urban_rural]
                        source_systems: [labmap, worldpop, osm]
                        frequency: "quarterly"
                        quality_rules: [completeness, validity]
                        approved_on: "2026-10-14"
                    page: { next_cursor: null, page_size: 100 }
        '401': { $ref: '#/components/responses/Unauthorized' }
      security:
        - oauth2: [indicators:read]

  /indicators/{indicator_id}:
    parameters:
      - name: indicator_id
        in: path
        required: true
        schema: { type: string, examples: ['ME-02'] }
    get:
      tags: [Indicators]
      operationId: getIndicator
      summary: Retrieve one indicator definition
      responses:
        '200':
          description: The indicator definition.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Indicator' }
        '404': { $ref: '#/components/responses/NotFound' }
      security:
        - oauth2: [indicators:read]

  /indicator-values:
    get:
      tags: [Indicators]
      operationId: listIndicatorValues
      summary: Query computed indicator values
      description: |
        The primary analytical endpoint. Returns values for one or more
        indicators across a geography and a period range, at the requested
        level of the hierarchy.

        Values are read from the pre-aggregated indicator mart, not computed
        on request, which is what keeps p95 latency under one second on a
        1 Mbps link. Every value carries its numerator, denominator, quality
        flags and provenance. A value that has been restated because late
        data arrived carries `restated_at` and the load that restated it.
      parameters:
        - name: indicator_id
          in: query
          required: true
          description: One or more indicator identifiers.
          schema:
            type: array
            items: { type: string }
          style: form
          explode: false
          example: ["ME-02", "DP-01"]
        - $ref: '#/components/parameters/GeoUnit'
        - name: level
          in: query
          description: Hierarchy level at which to aggregate. Defaults to the level of `geo_unit_id`.
          schema: { type: string, enum: [country, province, district, facility] }
        - name: period_start
          in: query
          required: true
          schema: { type: string, examples: ['2026-W01'] }
        - name: period_end
          in: query
          required: true
          schema: { type: string, examples: ['2026-W36'] }
        - name: disaggregation
          in: query
          description: Permitted disaggregation for the indicator; rejected with `invalid_disaggregation` if the catalogue does not allow it.
          schema: { type: string }
        - name: include_quality
          in: query
          description: Include the per-value quality-rule detail rather than flags alone.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of indicator values.
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/IndicatorValue' }
                  page: { $ref: '#/components/schemas/Page' }
              examples:
                weekly:
                  value:
                    data:
                      - indicator_id: "ME-02"
                        geo_unit_id: "CD-NK"
                        geo_unit_name: "Nord-Kivu"
                        period: "2026-W36"
                        value: 71.4
                        numerator: 25
                        denominator: 35
                        unit: "percent"
                        quality_flags: []
                        provenance:
                          source: "labmap+ona"
                          load_id: "ld_01J9Z8QW44"
                          observed_at: "2026-09-07"
                      - indicator_id: "DP-01"
                        geo_unit_id: "CD-NK"
                        geo_unit_name: "Nord-Kivu"
                        period: "2026-W36"
                        value: null
                        numerator: null
                        denominator: null
                        unit: "hours"
                        quality_flags: [no_data, source_late]
                        provenance:
                          source: "lis"
                          load_id: null
                          observed_at: null
                    page: { next_cursor: null, page_size: 100 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
      security:
        - oauth2: [indicators:read]

  /indicator-values:export:
    post:
      tags: [Indicators]
      operationId: exportIndicatorValues
      summary: Request a bulk export of indicator values
      description: |
        For consumers that need more than pagination comfortably delivers —
        an offline analysis pack, a DNO model run, a scheduled national
        report. The export is prepared asynchronously and fetched once ready.

        Exports are recorded in the audit log with the requesting principal,
        the filter applied and the row count, because an export is the moment
        data leaves the governed store.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [indicator_id, period_start, period_end, format]
              properties:
                indicator_id:
                  type: array
                  items: { type: string }
                geo_unit_id: { type: string }
                level: { type: string, enum: [country, province, district, facility] }
                period_start: { type: string }
                period_end: { type: string }
                format: { type: string, enum: [csv, xlsx, json, adx] }
      responses:
        '202':
          description: Export accepted and being prepared.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportJob' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
      security:
        - oauth2: [indicators:export]

  /exports/{export_id}:
    parameters:
      - name: export_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Indicators]
      operationId: getExport
      summary: Check an export and retrieve its download link
      responses:
        '200':
          description: Export status. When `status` is `ready`, `download_url` is a short-lived signed link.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportJob' }
        '404': { $ref: '#/components/responses/NotFound' }
      security:
        - oauth2: [indicators:export]

  # ---------------------------------------------------------------- Geography
  /geo-units:
    get:
      tags: [Geography]
      operationId: listGeoUnits
      summary: List administrative units
      description: |
        The administrative hierarchy as the platform holds it, cross-walked to
        DHIS2 organisation-unit UIDs and GADM codes. Geometry is returned only
        when asked for, because boundary payloads dominate response size on a
        constrained link; prefer the vector tile service for map rendering.
      parameters:
        - name: level
          in: query
          schema: { type: string, enum: [country, province, district, health_area] }
        - name: parent_id
          in: query
          schema: { type: string }
        - name: include_geometry
          in: query
          description: Include GeoJSON geometry. Off by default.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of geographic units.
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/GeoUnit' }
                  page: { $ref: '#/components/schemas/Page' }
        '401': { $ref: '#/components/responses/Unauthorized' }
      security:
        - oauth2: [geo:read]

  /geo/isochrones:
    get:
      tags: [Geography]
      operationId: getIsochrones
      summary: Travel-time catchment around one or more facilities
      description: |
        Returns the road travel-time catchment used by the population-access
        indicators (PA-01, PA-02), with the population inside it. This is the
        endpoint a Diagnostic Network Optimisation tool calls when it wants to
        test what happens if an instrument moves.
      parameters:
        - name: mfl_id
          in: query
          required: true
          schema:
            type: array
            items: { type: string }
          style: form
          explode: false
        - name: minutes
          in: query
          description: Travel-time bands in minutes.
          schema:
            type: array
            items: { type: integer, minimum: 5, maximum: 480 }
          style: form
          explode: false
          example: [60, 120, 240]
        - name: mode
          in: query
          schema: { type: string, enum: [driving, walking], default: driving }
        - name: include_population
          in: query
          schema: { type: boolean, default: true }
      responses:
        '200':
          description: Isochrone bands as a GeoJSON FeatureCollection, with population per band.
          content:
            application/geo+json:
              schema: { $ref: '#/components/schemas/IsochroneCollection' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
      security:
        - oauth2: [geo:read]

  /geo/referral-paths:
    get:
      tags: [Geography]
      operationId: getReferralPaths
      summary: Candidate referral laboratories for an origin, ranked
      description: |
        Given an origin geography, returns candidate laboratories that can
        perform the requested test, ranked by travel time, each with its
        current functional-instrument count, utilisation and turnaround time.

        This is the operational question during an outbreak — where do I send
        this sample so that a result comes back — expressed as one call.
      parameters:
        - name: origin_geo_unit_id
          in: query
          required: true
          schema: { type: string }
        - name: test_code
          in: query
          required: true
          description: LOINC or national test code the destination must offer.
          schema: { type: string }
        - name: max_minutes
          in: query
          schema: { type: integer, default: 480 }
        - name: require_functional_instrument
          in: query
          schema: { type: boolean, default: true }
        - name: limit
          in: query
          schema: { type: integer, default: 5, maximum: 25 }
      responses:
        '200':
          description: Ranked candidate destinations.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ReferralPath' }
              examples:
                ranked:
                  value:
                    data:
                      - mfl_id: "CD-NK-0007"
                        name: "Laboratoire de Référence de Goma"
                        travel_minutes: 96
                        distance_km: 71.2
                        functional_instruments: 3
                        utilisation_percent: 62
                        median_tat_hours: 18.5
                        provenance:
                          source: "labmap+lis"
                          observed_at: "2026-09-08"
        '400': { $ref: '#/components/responses/BadRequest' }
      security:
        - oauth2: [geo:read]

  # ------------------------------------------------------- Interoperability
  /sources:
    get:
      tags: [Interoperability]
      operationId: listSources
      summary: List catalogued source systems and their interoperability maturity
      description: |
        The landscape inventory, kept live rather than frozen in a report.
        This endpoint populates the interoperability-status module and is
        maintained by the national core team after handover.
      parameters:
        - name: interface_pattern
          in: query
          schema: { type: string, enum: [api, dhis2, adx, file_exchange, manual] }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of source systems.
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/SourceSystem' }
                  page: { $ref: '#/components/schemas/Page' }
      security:
        - oauth2: [sources:read]

  /loads:
    get:
      tags: [Interoperability]
      operationId: listLoads
      summary: List ingestion runs
      description: |
        Every load is recorded with its row counts, checksum and outcome, so a
        data steward can answer "why did that number change" without opening a
        database console.
      parameters:
        - name: source_id
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [succeeded, partial, failed, running] }
        - name: since
          in: query
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of loads.
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Load' }
                  page: { $ref: '#/components/schemas/Page' }
      security:
        - oauth2: [sources:read]

  /loads/{load_id}/reconciliation:
    parameters:
      - name: load_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Interoperability]
      operationId: getLoadReconciliation
      summary: Reconciliation report for one load
      description: |
        Compares what the source held with what the platform stored: row
        counts, checksums, facility identifier match rate and the records
        quarantined with their reason. This is the artefact that makes an
        integration auditable rather than merely functional.
      responses:
        '200':
          description: The reconciliation report.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Reconciliation' }
        '404': { $ref: '#/components/responses/NotFound' }
      security:
        - oauth2: [sources:read]

  /ingest/{source_id}:
    parameters:
      - name: source_id
        in: path
        required: true
        description: Catalogued source system identifier.
        schema: { type: string }
    post:
      tags: [Interoperability]
      operationId: ingestBatch
      summary: Deliver a batch of records from an authorised source system
      description: |
        The push half of the integration surface, for sources that can send
        rather than be polled. Accepts the platform's canonical JSON, a
        DHIS2 `dataValueSets` payload, an ADX document or a FHIR Bundle;
        the mediator validates against the declared schema before anything
        lands.

        Semantics: the whole batch is validated first. Records that fail
        validation are quarantined with a reason and reported in the
        response; valid records land. A batch is never partially applied
        silently. Send an `Idempotency-Key`: an ingestion client that retries
        after a timeout must not double-count a period.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IngestBatch' }
          application/fhir+json:
            schema:
              type: object
              description: FHIR R4 Bundle of MeasureReport and Observation resources.
          application/adx+xml:
            schema: { type: string, format: binary }
      responses:
        '202':
          description: Batch accepted. Valid records landed; rejected records itemised.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestResult' }
              examples:
                partial:
                  value:
                    load_id: "ld_01J9ZB3M7T"
                    received: 412
                    accepted: 408
                    quarantined: 4
                    rejections:
                      - row: 117
                        reason: "numerator_exceeds_denominator"
                        detail: "functional_instruments (9) > installed_instruments (7)"
                      - row: 203
                        reason: "unknown_facility"
                        detail: "No Master Facility List match for lis_code 'HGR-XYZ' above confidence threshold"
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413':
          description: Payload too large. Split the batch; the limit is returned in the problem detail.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
      security:
        - oauth2: [ingest:write]

  # --------------------------------------------------------------- Collection
  /submissions:
    get:
      tags: [Collection]
      operationId: listSubmissions
      summary: List ONA form submissions and their validation state
      description: |
        Gap-filling data — biosafety self-assessment, instrument functionality,
        reagent stock-out — collected through ONA and mirrored here with its
        validation state. Only submissions accepted by the national laboratory
        directorate contribute to indicators; the rest are visible but do not
        move a number.
      parameters:
        - name: form_id
          in: query
          schema: { type: string, examples: ['biosafety_assessment_v3'] }
        - name: mfl_id
          in: query
          schema: { type: string }
        - name: state
          in: query
          schema: { type: string, enum: [submitted, under_review, accepted, rejected] }
        - name: period
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of submissions.
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Submission' }
                  page: { $ref: '#/components/schemas/Page' }
      security:
        - oauth2: [submissions:read]

  /submissions/{submission_id}:validate:
    parameters:
      - name: submission_id
        in: path
        required: true
        schema: { type: string }
      - $ref: '#/components/parameters/IdempotencyKey'
    post:
      tags: [Collection]
      operationId: validateSubmission
      summary: Accept or reject a submission
      description: |
        The validation chain. A submission becomes indicator input only when a
        named reviewer accepts it, and the acceptance is written to the audit
        log with the reviewer, the time and any comment. Rejection requires a
        reason, so the facility learns what to fix.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision: { type: string, enum: [accept, reject] }
                reason:
                  type: string
                  description: Required when rejecting.
                comment: { type: string }
      responses:
        '200':
          description: The updated submission.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Submission' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Submission already validated. Includes the existing decision.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
      security:
        - oauth2: [submissions:validate]

  # ---------------------------------------------------------------------- Ask
  /ask:
    post:
      tags: [Ask]
      operationId: askQuestion
      summary: Answer a natural-language question from the governed indicator store
      description: |
        "Ask the Dashboard". A question in French or English is matched against
        the indicator catalogue, translated by an open-weights model hosted on
        national infrastructure into SQL over the indicator mart, and executed
        under the caller's own data scope.

        Three properties matter more than the convenience:

        1. **The model never produces the number.** It produces a query. The
           number comes from the database. The generated SQL is returned in
           the response so it can be inspected.
        2. **The answer always carries its source.** Indicator, period,
           geography, source system and freshness, plus a deep link to the
           same view in the dashboard.
        3. **Out of scope means refused.** A question the catalogue cannot
           answer returns `answerable: false` with the nearest indicators
           suggested, rather than a plausible sentence.

        Every question and answer is written to the audit log. The endpoint
        can be disabled per role by the Ministry of Health administrator.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question:
                  type: string
                  maxLength: 500
                  examples: ["Combien de laboratoires en Ituri peuvent tester Ebola aujourd'hui ?"]
                language: { type: string, enum: [fr, en] }
                geo_unit_id:
                  type: string
                  description: Optional context; must be inside the caller's data scope.
      responses:
        '200':
          description: The answer, or a refusal with suggestions.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AskAnswer' }
              examples:
                answered:
                  value:
                    answerable: true
                    question: "Combien de laboratoires en Ituri peuvent tester Ebola aujourd'hui ?"
                    answer:
                      value: 6
                      unit: "laboratories"
                      indicator_id: "LC-01"
                      geo_unit_id: "CD-IT"
                      geo_unit_name: "Ituri"
                      period: "2026-W36"
                    provenance:
                      source: "labmap"
                      observed_at: "2026-09-07"
                      freshness: "current"
                    generated_sql: "SELECT value FROM mart.indicator_value WHERE indicator_id='LC-01' AND geo_unit_id='CD-IT' AND period='2026-W36'"
                    dashboard_url: "https://lip.moh.example.gov/modules/capacity?geo=CD-IT&period=2026-W36"
                refused:
                  value:
                    answerable: false
                    question: "Which minister approved the reagent budget?"
                    reason: "no_matching_indicator"
                    suggestions:
                      - indicator_id: "DP-04"
                        name: "Reagent and consumable stock-out"
        '400': { $ref: '#/components/responses/BadRequest' }
        '403':
          description: The natural-language endpoint is disabled for this role, or the question referenced a geography outside the caller's scope.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
      security:
        - oauth2: [ask:query]

  # --------------------------------------------------------------- Operations
  /health:
    get:
      tags: [Operations]
      operationId: getHealth
      summary: Liveness and readiness
      description: Unauthenticated. Used by the load balancer and the monitoring stack.
      security: []
      responses:
        '200':
          description: Service healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok, degraded] }
                  version: { type: string }
                  checks:
                    type: object
                    additionalProperties: { type: string, enum: [ok, degraded, failed] }
        '503':
          description: Service not ready.

  /metadata/freshness:
    get:
      tags: [Operations]
      operationId: getFreshness
      summary: Data freshness by source and indicator domain
      description: |
        What is current, what is stale, and how stale. The dashboard renders
        this as freshness chips; consumers should surface it too.
      responses:
        '200':
          description: Freshness summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        source_id: { type: string }
                        domain: { type: string }
                        last_successful_load: { type: string, format: date-time }
                        expected_cadence: { type: string }
                        status: { type: string, enum: [current, at_risk, stale] }
      security:
        - oauth2: [sources:read]

components:

  securitySchemes:
    oauth2:
      type: oauth2
      description: |
        OpenID Connect provider (Keycloak). Scopes govern operations; the
        organisation-unit data scope is carried in the token and enforced
        server-side on every query.

        Multi-factor authentication is mandatory for any principal holding
        `indicators:export`, `submissions:validate`, `ingest:write` or
        administrative scopes.
      flows:
        clientCredentials:
          tokenUrl: https://id.moh.example.gov/realms/lip/protocol/openid-connect/token
          scopes: &scopes
            facilities:read: Read the facility register
            equipment:read: Read instrument inventory and status
            testmenu:read: Read facility test menus
            indicators:read: Read the indicator catalogue and computed values
            indicators:export: Request bulk exports of indicator values
            geo:read: Read geographic units, isochrones and referral paths
            sources:read: Read the source inventory, loads and reconciliation reports
            ingest:write: Deliver batches from an authorised source system
            submissions:read: Read ONA submissions and their validation state
            submissions:validate: Accept or reject submissions
            ask:query: Use the natural-language query endpoint
        authorizationCode:
          authorizationUrl: https://id.moh.example.gov/realms/lip/protocol/openid-connect/auth
          tokenUrl: https://id.moh.example.gov/realms/lip/protocol/openid-connect/token
          scopes: *scopes

  parameters:
    GeoUnit:
      name: geo_unit_id
      in: query
      description: Restrict to this administrative unit and its descendants. Must be inside the caller's data scope.
      schema: { type: string }
    Tier:
      name: tier
      in: query
      schema: { type: string, enum: [national_reference, provincial, district, primary, private, mobile] }
    Sector:
      name: sector
      in: query
      schema: { type: string, enum: [public, private, faith_based, military, research] }
    Fields:
      name: fields
      in: query
      description: Comma-separated attribute projection. Reduces payload size on constrained links.
      schema: { type: string, examples: ['mfl_id,name,vhf_ready'] }
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from `page.next_cursor`. Do not construct by hand.
      schema: { type: string }
    PageSize:
      name: page_size
      in: query
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-generated key (UUID recommended). Replaying a request with the
        same key within 24 hours returns the original result instead of
        applying the change again.
      schema: { type: string, maxLength: 128 }

  responses:
    BadRequest:
      description: The request was malformed or violated a catalogue constraint.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            invalidDisaggregation:
              value:
                type: "https://lip.africacdc.example.org/problems/invalid-disaggregation"
                title: "Disaggregation not permitted for this indicator"
                status: 400
                detail: "Indicator PA-01 permits [province, district, urban_rural]; 'sex' was requested."
                instance: "/v1/indicator-values"
                code: "invalid_disaggregation"
                trace_id: "01J9ZC1M4Q7X"
    Unauthorized:
      description: Missing, expired or invalid token.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: |
        The token is valid but lacks the scope, or the request reached outside
        the caller's organisation-unit data scope. The platform refuses rather
        than silently narrowing the result — a quietly filtered answer is
        worse than an error, because it looks like an answer.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            outOfScope:
              value:
                type: "https://lip.africacdc.example.org/problems/out-of-scope"
                title: "Requested geography is outside your data scope"
                status: 403
                detail: "Your access is scoped to CD-NK and its descendants."
                code: "out_of_scope"
                trace_id: "01J9ZC2P8R1A"
    NotFound:
      description: No such resource, or it is outside the caller's data scope.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          schema: { type: integer }
        RateLimit-Limit:
          schema: { type: integer }
        RateLimit-Remaining:
          schema: { type: integer }
        RateLimit-Reset:
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:

    Problem:
      type: object
      description: RFC 9457 problem details.
      required: [type, title, status]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        code:
          type: string
          description: Stable machine-readable error code. Clients should branch on this, not on `title`.
        trace_id:
          type: string
          description: Correlates with the platform log entry for this request.

    Page:
      type: object
      required: [page_size]
      properties:
        next_cursor:
          type: [string, 'null']
          description: Pass as `cursor` to fetch the next page. Null on the last page.
        page_size: { type: integer }

    Provenance:
      type: object
      description: Where a fact came from and when it was observed. Present on every data-bearing payload.
      properties:
        source: { type: string, examples: ['labmap', 'cdr', 'dhis2', 'lis', 'ona'] }
        load_id: { type: [string, 'null'] }
        observed_at: { type: [string, 'null'], format: date }
        freshness: { type: string, enum: [current, at_risk, stale, unknown] }

    Coordinates:
      type: object
      properties:
        latitude: { type: number, minimum: -90, maximum: 90 }
        longitude: { type: number, minimum: -180, maximum: 180 }

    Facility:
      type: object
      required: [mfl_id, name]
      properties:
        mfl_id: { type: string }
        name: { type: string }
        tier: { type: string }
        sector: { type: string }
        geo_unit_id: { type: string }
        coordinates: { $ref: '#/components/schemas/Coordinates' }
        capability_score:
          type: [integer, 'null']
          minimum: 0
          maximum: 100
        vhf_ready: { type: [boolean, 'null'] }
        identifiers:
          type: object
          properties:
            labmap_id: { type: [string, 'null'] }
            dhis2_org_unit: { type: [string, 'null'] }
            lis_code: { type: [string, 'null'] }
        identifier_match:
          type: object
          description: How this record was linked to the Master Facility List.
          properties:
            method: { type: string, enum: [deterministic, probabilistic, manual, unmatched] }
            confidence: { type: number, minimum: 0, maximum: 1 }
        provenance: { $ref: '#/components/schemas/Provenance' }

    FacilityDetail:
      allOf:
        - $ref: '#/components/schemas/Facility'
        - type: object
          properties:
            capability_components:
              type: object
              description: Sub-scores and the weights used, so the composite can be reproduced.
              properties:
                equipment: { type: integer }
                workforce: { type: integer }
                quality: { type: integer }
                biosafety: { type: integer }
                connectivity: { type: integer }
                weights:
                  type: object
                  additionalProperties: { type: number }
                approved_on: { type: string, format: date }
            readiness_flags:
              type: object
              properties:
                accreditation: { type: [string, 'null'] }
                eqa_enrolment: { type: [boolean, 'null'] }
                biosafety_level: { type: [string, 'null'] }
                stock_tracking: { type: [boolean, 'null'] }
            human_resources:
              type: array
              items:
                type: object
                properties:
                  cadre: { type: string }
                  count: { type: integer }
            equipment_summary:
              type: object
              properties:
                installed: { type: integer }
                functional: { type: integer }
            test_menu_summary:
              type: object
              properties:
                tests_offered: { type: integer }
                tier_expected: { type: integer }

    Equipment:
      type: object
      required: [equipment_id, instrument_type, status]
      properties:
        equipment_id: { type: string }
        mfl_id: { type: string }
        instrument_type: { type: string }
        model: { type: string }
        serial: { type: [string, 'null'] }
        status: { type: string, enum: [functional, down, decommissioned, unknown] }
        down_reason: { type: [string, 'null'], examples: ['awaiting_part', 'no_reagent', 'power', 'calibration'] }
        rated_throughput_per_day: { type: [integer, 'null'] }
        connectivity: { type: [boolean, 'null'] }
        last_reported: { type: [string, 'null'], format: date }
        provenance: { $ref: '#/components/schemas/Provenance' }

    TestMenuItem:
      type: object
      required: [test_code, status]
      properties:
        test_code: { type: string, description: National test code. }
        loinc_code:
          type: [string, 'null']
          description: Null where no national-to-LOINC mapping exists. The gap is reported, not hidden.
        display: { type: string }
        method: { type: string, examples: ['rt_pcr', 'rdt', 'cartridge_naat', 'culture'] }
        disease_programme: { type: string }
        status: { type: string, enum: [offered, suspended, withdrawn] }
        effective_from: { type: string, format: date }

    Indicator:
      type: object
      required: [id, domain, name, definition]
      properties:
        id: { type: string }
        domain: { type: string }
        name: { type: string }
        definition: { type: string }
        numerator: { type: string }
        denominator: { type: [string, 'null'] }
        unit: { type: string, examples: ['count', 'percent', 'hours'] }
        disaggregations:
          type: array
          items: { type: string }
        source_systems:
          type: array
          items: { type: string }
        frequency: { type: string, enum: [daily, weekly, monthly, quarterly, on_update] }
        quality_rules:
          type: array
          items: { type: string }
        composite:
          type: object
          description: Present only for composite indicators.
          properties:
            components:
              type: array
              items:
                type: object
                properties:
                  indicator_id: { type: string }
                  weight: { type: number }
            method: { type: string }
        owner: { type: string }
        approved_on: { type: string, format: date }

    IndicatorValue:
      type: object
      required: [indicator_id, period]
      properties:
        indicator_id: { type: string }
        geo_unit_id: { type: [string, 'null'] }
        geo_unit_name: { type: [string, 'null'] }
        mfl_id: { type: [string, 'null'] }
        period: { type: string }
        value:
          type: [number, 'null']
          description: Null when not reported. Never zero-filled.
        numerator: { type: [number, 'null'] }
        denominator: { type: [number, 'null'] }
        unit: { type: string }
        disaggregation:
          type: object
          additionalProperties: { type: string }
        quality_flags:
          type: array
          items:
            type: string
            enum: [no_data, source_late, incomplete, out_of_range, inconsistent, duplicate_resolved, estimated]
        restated_at: { type: [string, 'null'], format: date-time }
        provenance: { $ref: '#/components/schemas/Provenance' }

    ExportJob:
      type: object
      properties:
        export_id: { type: string }
        status: { type: string, enum: [queued, running, ready, failed, expired] }
        format: { type: string }
        row_count: { type: [integer, 'null'] }
        download_url:
          type: [string, 'null']
          description: Short-lived signed URL, valid 15 minutes, present only when status is `ready`.
        requested_by: { type: string }
        requested_at: { type: string, format: date-time }
        expires_at: { type: [string, 'null'], format: date-time }

    GeoUnit:
      type: object
      required: [geo_unit_id, level, name]
      properties:
        geo_unit_id: { type: string }
        level: { type: string, enum: [country, province, district, health_area] }
        name: { type: string }
        parent_id: { type: [string, 'null'] }
        dhis2_uid: { type: [string, 'null'] }
        gadm_code: { type: [string, 'null'] }
        population: { type: [integer, 'null'] }
        population_year: { type: [integer, 'null'] }
        geometry:
          type: [object, 'null']
          description: GeoJSON geometry. Returned only when `include_geometry=true`.

    IsochroneCollection:
      type: object
      description: GeoJSON FeatureCollection; each feature is one travel-time band.
      properties:
        type: { type: string, const: FeatureCollection }
        features:
          type: array
          items:
            type: object
            properties:
              type: { type: string, const: Feature }
              geometry: { type: object }
              properties:
                type: object
                properties:
                  mfl_id: { type: string }
                  minutes: { type: integer }
                  mode: { type: string }
                  population_inside: { type: integer }
                  population_source: { type: string, examples: ['worldpop_2025'] }
                  road_network_version: { type: string }

    ReferralPath:
      type: object
      properties:
        mfl_id: { type: string }
        name: { type: string }
        travel_minutes: { type: integer }
        distance_km: { type: number }
        functional_instruments: { type: integer }
        utilisation_percent: { type: [number, 'null'] }
        median_tat_hours: { type: [number, 'null'] }
        provenance: { $ref: '#/components/schemas/Provenance' }

    SourceSystem:
      type: object
      properties:
        source_id: { type: string }
        name: { type: string }
        custodian: { type: string }
        data_domains:
          type: array
          items: { type: string }
        interface_pattern: { type: string, enum: [api, dhis2, adx, file_exchange, manual] }
        standards:
          type: array
          items: { type: string, examples: ['fhir_r4', 'adx', 'dhis2_api', 'xlsform'] }
        maturity_level:
          type: integer
          minimum: 0
          maximum: 5
          description: 0 none · 1 manual export · 2 scheduled file exchange · 3 documented API · 4 standards-based API · 5 standards-based with terminology and registry alignment.
        expected_cadence: { type: string }
        dsa_status: { type: string, enum: [in_place, pending, not_required] }

    Load:
      type: object
      properties:
        load_id: { type: string }
        source_id: { type: string }
        started_at: { type: string, format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }
        status: { type: string, enum: [succeeded, partial, failed, running] }
        rows_received: { type: integer }
        rows_accepted: { type: integer }
        rows_quarantined: { type: integer }
        checksum: { type: [string, 'null'] }

    Reconciliation:
      type: object
      properties:
        load_id: { type: string }
        source_id: { type: string }
        period: { type: string }
        source_row_count: { type: [integer, 'null'] }
        platform_row_count: { type: integer }
        checksum_match: { type: [boolean, 'null'] }
        facility_match_rate:
          type: number
          description: Share of source facility records matched to the Master Facility List (indicator IO-03).
        unmatched_facilities:
          type: array
          items:
            type: object
            properties:
              source_identifier: { type: string }
              source_name: { type: string }
              best_candidate_mfl_id: { type: [string, 'null'] }
              confidence: { type: [number, 'null'] }
        quarantined:
          type: array
          items:
            type: object
            properties:
              row: { type: integer }
              reason: { type: string }
              detail: { type: string }

    IngestBatch:
      type: object
      required: [period, records]
      properties:
        period: { type: string }
        format: { type: string, enum: [canonical, dhis2_datavaluesets, adx, fhir], default: canonical }
        records:
          type: array
          maxItems: 5000
          items:
            type: object
            properties:
              mfl_id: { type: string }
              indicator_id: { type: string }
              value: { type: [number, 'null'] }
              numerator: { type: [number, 'null'] }
              denominator: { type: [number, 'null'] }
              observed_at: { type: string, format: date }

    IngestResult:
      type: object
      properties:
        load_id: { type: string }
        received: { type: integer }
        accepted: { type: integer }
        quarantined: { type: integer }
        rejections:
          type: array
          items:
            type: object
            properties:
              row: { type: integer }
              reason: { type: string }
              detail: { type: string }

    Submission:
      type: object
      properties:
        submission_id: { type: string }
        form_id: { type: string }
        form_version: { type: string }
        mfl_id: { type: string }
        geo_unit_id: { type: string }
        period: { type: string }
        submitted_at: { type: string, format: date-time }
        state: { type: string, enum: [submitted, under_review, accepted, rejected] }
        validated_by: { type: [string, 'null'] }
        validated_at: { type: [string, 'null'], format: date-time }
        rejection_reason: { type: [string, 'null'] }
        attachment_count: { type: integer }
        responses:
          type: object
          description: Form responses, keyed by XLSForm field name.
          additionalProperties: true

    AskAnswer:
      type: object
      required: [answerable, question]
      properties:
        answerable: { type: boolean }
        question: { type: string }
        answer:
          type: [object, 'null']
          properties:
            value: { type: [number, string, 'null'] }
            unit: { type: string }
            indicator_id: { type: string }
            geo_unit_id: { type: string }
            geo_unit_name: { type: string }
            period: { type: string }
        provenance: { $ref: '#/components/schemas/Provenance' }
        generated_sql:
          type: [string, 'null']
          description: The SQL the model produced and the database executed. Returned for inspection; the number is the database's, not the model's.
        dashboard_url:
          type: [string, 'null']
          description: Deep link to the same view in the dashboard.
        reason:
          type: [string, 'null']
          enum: [no_matching_indicator, out_of_scope, ambiguous, disabled_for_role, null]
        suggestions:
          type: array
          items:
            type: object
            properties:
              indicator_id: { type: string }
              name: { type: string }
