openapi: 3.1.0
info:
  title: CCA Geocode API
  version: 0.4.0
  description: |
    Global and sub-national geocoding for the CCA data-service fleet — localities, street
    addresses, postcodes, admin areas, reverse geocoding, and unified point-of-interest search.
    POI search combines named OpenStreetMap landmarks with authoritative school,
    university, church, and nonprofit records through private fleet service bindings. US locality
    coverage comes from Census Places and County Subdivisions Gazetteers
    (cities, towns, boroughs, villages and CDPs); GeoNames supplies postal
    localities plus an owned global cities/capitals index. Postal coverage: United States (Census
    ZCTA), United Kingdom, Canada and Australia (GeoNames postal areas, CC BY
    4.0). Street-address coverage: US (US DOT National Address
    Database rooftop points, public domain, with Census TIGER/Line ADDRFEAT
    range interpolation as fallback), Australia (Geoscape G-NAF rooftop
    points, CC BY 4.0), Canada (StatCan Open Database of Addresses rooftop
    points, OGL-Canada) and Great Britain (OSM cross-reference — see below).
    Every other valid ISO 3166-1 alpha-2 country and territory first tries exact
    city/alias resolution from GeoNames cities15000 before bounded address,
    facility, small-locality, and reverse fallback through the existing fleet
    MAPS service binding (OpenStreetMap/Nominatim).

    Street matches below rooftop accuracy are cross-referenced at read time
    against OpenStreetMap address points through the fleet maps worker
    (maps.datasourceapi.com). When OSM independently corroborates the house
    number, street and postal area, its surveyed point replaces the
    interpolated one and the match upgrades to `rooftop` with
    `match.refined_by: osm-nominatim` and the applied `offset_m`.

    Every JSON response uses the fleet envelope
    `{ request_status, message, [error_code], data, meta }`; `meta.sources`
    names the dataset(s) that answered (`census-zcta`, `geonames-postal`, `geonames-cities`,
    `dot-nad`, `census-tiger`, `geoscape-gnaf`, `statcan-oda`,
    `osm-nominatim`, `cca-schools`, `cca-exemptorgs`).
servers:
  - url: https://geocode.datasourceapi.com
x-cloudflare-service-binding:
  binding: GEOCODE
  service: cca-geocode
  usage: env.GEOCODE.fetch(new Request('https://geocode.internal/api/v1/place?q=Newtown%2C%20CT&cc=us'))
paths:
  /api/v1/geocode:
    get:
      summary: Postcode, address, facility, or locality → coordinates + context
      description: |
        Resolves a postcode to its centroid and administrative areas. US ZIPs
        resolve via 2024 Census ZCTA centroids and the 2020 ZCTA↔county
        relationship file; `county` is the largest-land-overlap county and
        `counties` lists all overlaps with `land_share`.

        GB / CA / AU resolve via GeoNames postal-area centroids: GB outward
        codes (`SW1A`; full postcodes reduce automatically), Canadian forward
        sortation areas (`K1A`), and 4-digit Australian postcodes. The payload
        carries the locality list (`places`) and a mean centroid.

        With `?address=` and any valid ISO alpha-2 `cc`, a one-line street
        address, airport/facility name, or locality is resolved. `us`, `au`,
        `ca`, and `gb` use the mirrored address/postal datasets.
        `data.match.type` reports the accuracy ladder: `rooftop` (exact
        NAD/G-NAF/ODA point, or an OSM-corroborated upgrade) → `interpolated`
        (US TIGER range + parity hit) → `street` (nearest number on the
        street, clamped) → `postal` (centroid fallback when the street
        missed). US matches report which dataset answered in `match.source`:
        `dot-nad` (US DOT National Address Database rooftop point —
        authoritative, never OSM-refined) or `census-tiger` (interpolation
        fallback; both stay live, NAD simply outranks). Sub-rooftop matches
        are cross-referenced against OSM via the fleet maps worker; a
        corroborated hit sets `match.refined_by: osm-nominatim` and
        `match.offset_m`. US matches also return the county chain; sources
        are `dot-nad` (public domain), `census-tiger`,
        `geoscape-gnaf` (G-NAF © Geoscape Australia, CC BY 4.0),
        `statcan-oda` (OGL-Canada) and/or `osm-nominatim` (ODbL). GB has no
        open address dataset, so GB addresses resolve through the OSM
        cross-reference alone, anchored to the outward-code centroid —
        include a postcode or town.

        Resilience: common spelling variants match transparently
        (`Mt ↔ Mount`, `St ↔ Saint`, `Ft ↔ Fort`, spelled-out ordinals,
        dropped directionals). A city without a state probes same-named
        cities in every state. A query with no locality at all (`100 chief
        nimham cir`) is resolved by OSM locality inference: corroborated
        OSM candidates nominate the postcode, the native matcher confirms
        it, and `match.inferred_locality` reports the assumption — unless
        the address is ambiguous across towns, which 404s rather than
        guessing.

        Other valid ISO countries and territories first resolve exact city,
        ASCII-name, and embedded alternate-name matches from the mirrored
        GeoNames cities15000 dataset. Duplicate names rank by population and
        expose `alternatives`. Facility, street-address, and small-locality
        misses use a country-constrained OSM/Nominatim lookup through the fleet
        `MAPS` service binding. The caller IP is not exposed upstream. OSM
        results must match either the requested country code or an explicit
        ISO 3166-2 territory subdivision. Facility/locality hits remain
        `precision: place`; an uncorroborated OSM house result is at most
        `precision: street`, never `rooftop`.

        Forward validation is shared by owned and fallback paths: `postal` and
        `address` inputs are limited to 500 characters and may not contain ASCII
        control characters.

        Limitation: US PO-box and unique-building ZIPs have no ZCTA → 404.
      parameters:
        - name: postal
          in: query
          description: Postal code (required unless `address` is given)
          schema: { type: string, maxLength: 500 }
          example: "10001"
        - name: address
          in: query
          description: One-line street address, facility/airport name, or locality.
          schema: { type: string, maxLength: 500 }
          example: "1600 Pennsylvania Ave NW Washington DC 20500"
        - name: cc
          in: query
          description: ISO 3166-1 alpha-2 country or territory code. Case-insensitive; `uk` aliases to `gb`.
          schema: { type: string, default: us }
      responses:
        '200': { description: Resolved postcode }
        '400': { description: Invalid input }
        '404': { description: Unknown postcode / no ZCTA }
        '501': { description: Invalid country code }
        '503': { description: Global city index missed and the MAPS geocoder is unavailable }
  /api/v1/reverse:
    get:
      summary: Point → country, locality, postcode, and admin context
      description: |
        Approximation — returns the nearest centroid within `max_km`
        (default 50, max 200). `cc=us` (default) searches ZCTA centroids and
        returns the county chain (`meta.approximation` =
        `nearest-zcta-centroid`); `cc=gb|ca|au` searches GeoNames locality
        centroids (`nearest-locality-centroid`). True point-in-polygon
        arrives with boundary tiles in a later phase. Other valid ISO countries
        and territories use the OSM reverse endpoint through the `MAPS` service
        binding and require the returned country or ISO 3166-2 subdivision to
        match `cc`. Global results preserve the resolved OSM feature coordinates,
        return their haversine `distance_km` from the query point, and 404 when
        that distance exceeds `max_km`.

        `max_km` defaults to 50 when omitted or blank. It must be numeric and
        positive for every country path (`400` otherwise); values above 200 are
        capped at 200 km.
      parameters:
        - name: lat
          in: query
          required: true
          schema: { type: number }
        - name: lon
          in: query
          required: true
          schema: { type: number }
        - name: max_km
          in: query
          description: Positive search radius in km. Values above 200 are capped at 200.
          schema: { type: number, default: 50, exclusiveMinimum: 0 }
        - name: cc
          in: query
          description: ISO 3166-1 alpha-2 country or territory code. Case-insensitive; `uk` aliases to `gb`.
          schema: { type: string, default: us }
      responses:
        '200': { description: Nearest postcode area }
        '400': { description: Invalid input }
        '404': { description: Nothing within max_km }
        '501': { description: Invalid country code }
        '503': { description: Global MAPS reverse geocoder unavailable }
  /api/v1/search:
    get:
      summary: Unified point-of-interest search
      description: |
        Searches named landmarks, schools, universities, churches, and nonprofit
        organizations. General landmarks come from OpenStreetMap/Nominatim through
        the existing `MAPS` service binding. Education records come from
        `cca-schools` through `SCHOOLS`; churches and nonprofits come from
        `cca-exemptorgs` through `EXEMPTORGS`. Both authoritative providers use
        an explicit `type=poi` mode so shared public caches cannot mix legacy and
        POI result contracts. No downstream Worker is called over
        its public hostname.

        Results are normalized into one deterministic contract, de-duplicated by
        stable ID and co-located normalized name, then ranked by name match and
        authoritative-source priority. Candidates still tied after those fields
        use bounded provider relevance (currently Nominatim `importance` for OSM)
        before deterministic category/name/ID ordering. Provider relevance is a
        private sort key: it does not change public `score` or `confidence`, cannot
        outrank a stronger lexical match or an equally matched authoritative
        source, and is not returned. Only records with usable coordinates are
        returned. Bounding boxes use `[west, south, east, north]`; point-only
        sources return `null`. Category filtering happens after bounded candidate
        retrieval (10 MAPS candidates and 20 per authoritative provider), before
        the caller's final result limit is applied.

        Partial upstream failures remain visible in `meta.partial` and
        `meta.upstreams` while available results are returned. The endpoint returns
        `503` only when every attempted source is unavailable or fails. A source
        not needed by a `types` filter reports `status: skipped`.

        Fleet Workers call this endpoint through their `GEOCODE` service binding.
        The maps Worker exposes a separate `/api/v1/search` facade through that
        binding while keeping its `/api/v1/geocode` path Nominatim-only; this
        preserves the non-recursive call graph. Each request receives one trace
        identifier. A supplied `X-Request-ID` is preserved only when it is 1–128
        ASCII characters matching `[A-Za-z0-9][A-Za-z0-9._:-]*`; otherwise the
        Worker generates a UUID. The same value is exposed in the response header,
        `meta.request_id`, and every downstream service-binding request.
      parameters:
        - name: X-Request-ID
          in: header
          required: false
          description: Optional conservative printable trace token; invalid values are replaced, never reflected.
          schema:
            type: string
            minLength: 1
            maxLength: 128
            pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$'
        - name: q
          in: query
          required: true
          description: POI name or partial name.
          schema: { type: string, minLength: 2, maxLength: 160 }
          examples:
            landmark: { value: 'Statue of Liberty' }
            hotel: { value: 'Paris Las Vegas' }
            university: { value: 'Liberty University' }
            church: { value: 'First Baptist Church' }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 20, default: 10 }
        - name: cc
          in: query
          description: Optional ISO 3166-1 alpha-2 country filter; `uk` aliases to `gb`.
          schema: { type: string }
        - name: types
          in: query
          description: Comma-separated subset of `landmark,school,university,church,nonprofit`.
          schema: { type: string }
          example: school,university
      responses:
        '200':
          description: Ranked POIs; the list may be empty or partial.
          headers:
            X-Request-ID: { $ref: '#/components/headers/RequestId' }
          content:
            application/json:
              schema:
                type: object
                required: [request_status, message, data, meta]
                properties:
                  request_status: { const: success }
                  message: { type: string }
                  data:
                    type: object
                    required: [query, result_count, results]
                    properties:
                      query:
                        type: object
                        required: [q, normalized_query, cc, types, limit]
                        properties:
                          q: { type: string }
                          normalized_query: { type: string }
                          cc: { type: [string, 'null'] }
                          types:
                            type: array
                            items: { type: string, enum: [landmark, school, university, church, nonprofit] }
                          limit: { type: integer }
                      result_count: { type: integer }
                      results:
                        type: array
                        items: { $ref: '#/components/schemas/PoiResult' }
                  meta:
                    type: object
                    required: [request_id, sources, partial, upstreams, attribution, response_time_ms]
                    properties:
                      request_id: { type: string }
                      sources: { type: array, items: { type: string } }
                      partial: { type: boolean }
                      upstreams:
                        type: object
                        additionalProperties:
                          type: object
                          required: [status, result_count]
                          properties:
                            status: { type: string, enum: [ok, error, timeout, unavailable, skipped] }
                            result_count: { type: integer }
                            response_time_ms: { type: integer }
                            message: { type: string }
                      attribution: { type: array, items: { type: string } }
                      response_time_ms: { type: integer }
        '400':
          description: Missing/malformed query, type filter, or result limit
          headers:
            X-Request-ID: { $ref: '#/components/headers/RequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TracedError' }
        '499':
          description: Caller cancelled the request
          headers:
            X-Request-ID: { $ref: '#/components/headers/RequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TracedError' }
        '501':
          description: Invalid country code
          headers:
            X-Request-ID: { $ref: '#/components/headers/RequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TracedError' }
        '503':
          description: Every attempted POI source is unavailable
          headers:
            X-Request-ID: { $ref: '#/components/headers/RequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TracedError' }
        '500':
          description: Internal search failure
          headers:
            X-Request-ID: { $ref: '#/components/headers/RequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TracedError' }
  /api/v1/suggest:
    get:
      summary: Bounded autocomplete and autocorrection candidates
      description: |
        Completes or corrects partial localities, postal codes, and US/AU/CA
        street addresses anchored by a postal code or locality. Candidate retrieval
        uses only the service's mirrored Census, GeoNames, NAD, and TIGER D1
        indexes; it never calls an external provider or public fleet HTTP
        endpoint. Queries are capped at 160 characters and results at 10. Each
        locality index uses at most six probes (up to four equivalent exact
        prefixes plus two fuzzy fallbacks), each capped at 80 rows. Address
        locality expansion probes at most four equivalent city forms and uses
        at most 12 postal anchors.
        One-character input returns an empty successful response without D1.

        Ranking is deterministic. `ambiguous: true` and
        `safe_to_submit: false` require the caller to render an explicit
        selection instead of silently taking rank 1. A candidate can be
        displayed when `safe_to_select` is true. Automatic submission requires
        both response and candidate `safe_to_submit` to be true.

        Fleet callers use `env.GEOCODE.fetch()` with the synthetic
        `https://geocode.internal` hostname. Suggestion responses use an
        internal five-minute Cache API entry; client responses are `no-store`
        so zone/browser rules cannot retain stale suggestions.
      parameters:
        - name: q
          in: query
          required: true
          description: Partial or misspelled locality, postal code, or anchored address.
          schema: { type: string, minLength: 1, maxLength: 160 }
          examples:
            typo: { value: 'Newtownn, CT' }
            prefix: { value: 'Newt' }
            postal: { value: '100' }
            address: { value: '100 Cheif Nimham Cir Carmel NY' }
        - name: cc
          in: query
          schema: { type: string, enum: [us, gb, ca, au], default: us }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 10, default: 5 }
      responses:
        '200':
          description: Ranked explicit suggestions; the list may be empty.
          headers:
            Cache-Control:
              schema: { type: string, example: 'private, no-store' }
            X-Geocode-Cache:
              description: Whether the bounded suggestion response came from the edge cache.
              schema: { type: string, enum: [HIT, MISS] }
          content:
            application/json:
              schema:
                type: object
                required: [request_status, message, data, meta]
                properties:
                  request_status: { const: success }
                  message: { type: string }
                  data:
                    type: object
                    required: [cc, query, min_query_length, result_count, ambiguous, safe_to_submit, suggestions]
                    properties:
                      cc: { type: string }
                      query: { type: object }
                      min_query_length: { type: integer, const: 2 }
                      result_count: { type: integer }
                      ambiguous: { type: boolean }
                      safe_to_submit: { type: boolean }
                      suggestions:
                        type: array
                        items: { $ref: '#/components/schemas/Suggestion' }
                  meta: { type: object }
        '400': { description: Missing, malformed, overlong, or invalid limit }
        '499': { description: Caller cancelled the request }
        '501': { description: Country not loaded }
  /api/v1/place:
    get:
      summary: General forward lookup (place, postal code, or full address)
      description: |
        `q` is the preferred fleet contract and accepts:

        * Locality: `Newtown, CT`, `Newtown, Connecticut`, or `Springfield`
        * Postal code: `10001`, `K1A 0B1`, `SW1A 1AA`, or `2000`
        * Full one-line address: `1600 Pennsylvania Ave NW, Washington, DC 20500`
        * Global facility: `Nadi International Airport` with `cc=fj`

        Explicit `city` + optional `state` remains supported. US state names
        normalize to USPS codes. US locality lookup prefers authoritative 2024
        Census Places and County Subdivisions Gazetteers, covering incorporated
        places, Census-designated places, towns, townships, villages and
        boroughs. GeoNames postal data is the fallback and serves `gb`, `ca`,
        and `au`. Other valid ISO countries and territories use the owned
        GeoNames cities15000 index for exact city/alias matches before a
        country-constrained OSM lookup through the fleet `MAPS` service binding.

        Every successful result adds backward-compatible `correction` metadata.
        A unique safe spelling correction may be applied. Ambiguous corrections
        instead return explicit ranked candidates with `safe_to_submit: false`.
        Because `MT` can mean either Montana or Mount, use `Billings, MT` for
        the state interpretation; unpunctuated terminal `Mt` is treated as a
        locality abbreviation such as `Rocky Mt`.

        Shared names without a state return `ambiguous: true` and deterministically
        ranked candidates. A state-constrained name selects the strongest
        authoritative match and may include lower-ranked `alternatives`; for
        example, `Newtown, CT` selects `Newtown town` over `Newtown borough`,
        while preserving both Census labels.

        All resolved forward points add `label`, `precision`,
        `precision_class`, `provider`, `source`, `score`, `confidence`,
        `suitability`, and `provenance` without removing legacy fields.
        `precision_class: centroid` (`place`/`postal`) is valid as a search
        origin but not as an individual institution location. Callers plotting
        a school, church, institution, or organization must require
        `suitability.feature_location: true`. A full-address lookup that falls
        back to a postal centroid stays `precision: postal`; it is never silently
        upgraded to address precision.

        Fleet Workers call this path with the `GEOCODE` service binding:
        `env.GEOCODE.fetch(new Request('https://geocode.internal/api/v1/place?...'))`.
        The hostname is syntactic; do not call the public fleet hostname with
        global `fetch()`.
      parameters:
        - name: q
          in: query
          description: Locality, postal code, or full one-line address.
          schema: { type: string }
          examples:
            locality: { value: 'Newtown, CT' }
            postal: { value: '06470' }
            address: { value: '3 Primrose St, Newtown, CT 06470' }
        - name: city
          in: query
          description: Locality name. Overrides locality parsing/dispatch from `q`.
          schema: { type: string }
        - name: state
          in: query
          description: State/region code or full name to disambiguate (e.g. `CT` or `Connecticut`).
          schema: { type: string }
        - name: cc
          in: query
          description: ISO 3166-1 alpha-2 country or territory code. Case-insensitive; `uk` aliases to `gb`.
          schema: { type: string, default: us }
      responses:
        '200':
          description: >
            A resolved place/postal/address point, or
            `{ ambiguous: true, candidates }` for an unconstrained duplicate name.
          content:
            application/json:
              examples:
                resolved:
                  summary: Authoritative Census town centroid
                  value:
                    request_status: success
                    message: OK
                    data:
                      cc: us
                      type: place
                      query: { city: Newtown, state: CT }
                      input: { q: 'Newtown, CT', kind: place }
                      place: Newtown
                      matched_label: Newtown town
                      feature_type: town
                      geoid: '0919052980'
                      label: Newtown town, Connecticut, United States
                      lat: 41.399503
                      lon: -73.293982
                      state: CT
                      admin1: { name: Connecticut, code: CT }
                      admin2: { name: Western Connecticut Planning Region, code: '190' }
                      precision: place
                      precision_class: centroid
                      provider: U.S. Census Bureau
                      source: census-cousub
                      score: 0.98
                      confidence: high
                      suitability: { search_origin: true, feature_location: false }
                      provenance:
                        source: census-cousub
                        provider: U.S. Census Bureau
                        dataset: Census County Subdivisions Gazetteer
                        vintage: '2024'
                        license: public-domain
                        method: internal-point
                        record_id: '0919052980'
                        feature_type: town
                    meta:
                      sources: [census-cousub, census-place]
                      attribution: U.S. Census Bureau Gazetteer Files (public domain)
                ambiguous:
                  summary: Name shared across states
                  value:
                    request_status: success
                    message: OK
                    data:
                      cc: us
                      type: place
                      query: { q: Springfield }
                      ambiguous: true
                      candidates:
                        - { rank: 1, place: Springfield, state: IL, precision: place, precision_class: centroid, suitability: { search_origin: true, feature_location: false } }
                        - { rank: 2, place: Springfield, state: MO, precision: place, precision_class: centroid, suitability: { search_origin: true, feature_location: false } }
        '400': { description: 'Missing/malformed input or invalid US state name/code' }
        '404': { description: 'No matching locality, postal area, or address' }
        '501': { description: 'Invalid country code' }
        '503': { description: 'Global city index missed and the MAPS geocoder is unavailable' }
  /api/v1/status:
    get:
      summary: Health + dataset freshness
      responses:
        '200': { description: Service status, table counts, global country coverage, and ingest ledger }
  /api/v1/admin/datasets:
    get:
      summary: Dataset registry + ingest ledger (admin)
      security: [ { adminKey: [] } ]
      responses:
        '200': { description: Datasets }
        '401': { description: Missing/invalid X-Admin-Key }
  /api/v1/admin/ingest:
    post:
      summary: Ingest all datasets (admin)
      description: Fetches each Census source, archives the raw file to R2, parses, and reloads D1. `?if_changed=1` skips sources whose Last-Modified matches the ledger.
      security: [ { adminKey: [] } ]
      responses:
        '200': { description: Ingest results }
        '207': { description: Partial failure }
        '401': { description: Missing/invalid X-Admin-Key }
  /api/v1/admin/ingest/{dataset}:
    post:
      summary: Ingest one dataset (admin)
      security: [ { adminKey: [] } ]
      parameters:
        - name: dataset
          in: path
          required: true
          schema: { type: string, enum: [zcta-gaz, county-gaz, zcta-county-rel, us-places, us-cousubs, gb-postal, ca-postal, au-postal, us-postal, global-cities] }
      responses:
        '200': { description: Ingest result }
        '401': { description: Missing/invalid X-Admin-Key }
        '404': { description: Unknown dataset }
  /api/v1/admin/addr/ingest/{cc}:
    post:
      summary: Seed the street-address ingest pipeline for one country (admin)
      description: |
        Queues the bulk address mirror for `us` (TIGER/Line ADDRFEAT, one
        task per county), `au` (G-NAF full download, staged per state) or
        `ca` (StatCan ODA, one task per province). Progress is tracked in
        `addr_progress` (see `/api/v1/admin/addr/progress` and
        `/api/v1/status`). `?county=GEOID` (us) or `?prov=XX` (ca) runs a
        single unit instead — handy for spot re-runs.
      security: [ { adminKey: [] } ]
      parameters:
        - name: cc
          in: path
          required: true
          schema: { type: string, enum: [us, au, ca] }
      responses:
        '200': { description: Seeded }
        '401': { description: Missing/invalid X-Admin-Key }
  /api/v1/admin/addr/progress:
    get:
      summary: Street-address ingest progress by dataset (admin)
      security: [ { adminKey: [] } ]
      responses:
        '200': { description: Task counts, row totals and statuses per dataset }
        '401': { description: Missing/invalid X-Admin-Key }
components:
  headers:
    RequestId:
      description: Safe request trace identifier, also returned as `meta.request_id`.
      schema: { type: string }
  schemas:
    TracedError:
      type: object
      required: [request_status, error_code, message, meta]
      properties:
        request_status: { const: error }
        error_code: { type: string }
        message: { type: string }
        data: {}
        meta:
          type: object
          required: [request_id]
          properties:
            request_id: { type: string }
    PoiResult:
      type: object
      required:
        - poi_id
        - rank
        - name
        - label
        - result_kind
        - category
        - subtype
        - address
        - lat
        - lon
        - bbox
        - source
        - provider
        - score
        - confidence
        - distance_km
        - detail_url
        - provenance
      properties:
        poi_id: { type: string, description: Stable source-qualified identifier. }
        rank: { type: integer, minimum: 1 }
        name: { type: string }
        label: { type: string }
        result_kind: { type: string, const: poi }
        category: { type: string, enum: [landmark, school, university, church, nonprofit] }
        subtype: { type: [string, 'null'] }
        address:
          type: object
          required: [address1, address2, locality, region, postal, country_code, formatted]
          properties:
            address1: { type: [string, 'null'] }
            address2: { type: [string, 'null'] }
            locality: { type: [string, 'null'] }
            region: { type: [string, 'null'] }
            postal: { type: [string, 'null'] }
            country_code: { type: [string, 'null'] }
            formatted: { type: [string, 'null'] }
        lat: { type: number, minimum: -90, maximum: 90 }
        lon: { type: number, minimum: -180, maximum: 180 }
        bbox:
          type: [array, 'null']
          minItems: 4
          maxItems: 4
          items: { type: number }
          description: '[west, south, east, north], or null for point-only sources.'
        source: { type: string, enum: [osm-nominatim, cca-schools, cca-exemptorgs] }
        provider: { type: string }
        score: { type: number, minimum: 0, maximum: 1 }
        confidence: { type: string, enum: [low, medium, high] }
        distance_km: { type: [number, 'null'] }
        detail_url: { type: [string, 'null'], format: uri }
        provenance: { type: object }
    Suggestion:
      type: object
      required:
        - suggestion_id
        - rank
        - label
        - normalized_query
        - corrected_query
        - result_kind
        - context
        - state
        - postal
        - postals
        - precision
        - precision_class
        - suitability
        - provider
        - source
        - score
        - confidence
        - provenance
        - correction
        - safe_to_select
        - safe_to_submit
      properties:
        suggestion_id: { type: string, description: Stable source-backed candidate identifier. }
        rank: { type: integer, minimum: 1 }
        label: { type: string }
        normalized_query: { type: string }
        corrected_query: { type: string, description: 'Stable value to pass to /api/v1/place?q=.' }
        result_kind: { type: string, enum: [locality, postal, address] }
        context: { type: object }
        state: { type: [string, 'null'] }
        postal: { type: [string, 'null'] }
        postals: { type: array, items: { type: string } }
        lat: { type: number }
        lon: { type: number }
        precision: { type: string, enum: [place, postal, street, interpolated, rooftop] }
        precision_class: { type: string, enum: [centroid, address] }
        suitability:
          type: object
          required: [search_origin, feature_location]
          properties:
            search_origin: { type: boolean }
            feature_location: { type: boolean }
        provider: { type: string }
        source: { type: string }
        score: { type: number, minimum: 0, maximum: 1 }
        confidence: { type: string, enum: [low, medium, high] }
        provenance: { type: object }
        correction:
          type: object
          required: [original_query, normalized_query, corrected_query, applied, correction_type, edit_distance]
          properties:
            original_query: { type: string }
            normalized_query: { type: string }
            corrected_query: { type: string }
            applied: { type: boolean }
            correction_type: { type: string, enum: [none, completion, spelling, abbreviation, state, ambiguous] }
            edit_distance: { type: [integer, 'null'] }
        safe_to_select: { type: boolean }
        safe_to_submit: { type: boolean }
  securitySchemes:
    adminKey:
      type: apiKey
      in: header
      name: X-Admin-Key
