openapi: '3.1.0'
info:
  title: Motionworks API - Places Library
  version: 2.5.0
  description: >
    POI-anchored places library (ADR-024). One Place record per DATAPLOR
    POI; the Motionworks measured place a POI maps to is carried as
    informational `measured_*` columns rather than as the row identity.
    Multi-POI-per-measured-place is intentional signal, not a duplicate.


    ## Vector tile delivery (ADR-027 PR-T1.5)

    Mapbox Vector Tile (`.mvt`) delivery for Motionworks Places data —
    drop measured-place and POI Universe point layers directly into
    MapLibre, Mapbox GL JS, or any TileJSON-compatible map. Mint a
    grant scoped to your origins, fetch the TileJSON capability URL,
    and point your map at it; tiles render in seconds.


    Tile traffic is metered at **1 credit per 1,000 tiles served**
    (op id `places_tiles_fetch`). The grant + TileJSON control-plane
    endpoints are free. **Grants require a metered, licensed
    organization** — credit-pack-only orgs receive a 403
    (`TILE_GRANTS_REQUIRE_METERED`) on grant-create and TileJSON
    until the metered rail is enabled.


    ### Workflow

    1. **Mint a grant** (`POST /v2/places/tiles/grants`) — server-side,
       authenticated with your Supabase JWT or `X-API-Key`. Specify
       `allowed_origins` for the browser sites that will render the map.
    2. **Use the returned TileJSON URL**
       (`GET /v2/places/tiles/grants/{id}/tilejson`) — this is a
       **capability URL**: the opaque `grant_id` in the path IS the
       credential. Drop it straight into MapLibre's vector source.
    3. **MapLibre fetches the tiles for you**
       (`GET /v2/places/tiles/{layer}/{z}/{x}/{y}.mvt?token=…`) —
       extracts the `?token=` JWT from TileJSON and appends it on every
       tile request. You will not call the `.mvt` endpoint directly.


    Two layers ship today (both static, zoom 0..14):
    `points` (measured-place points) and `poi_points` (POI Universe).


    Designed per [ADR-027 — Vector Tile Endpoints](https://github.com/InterMx/api-mworks-com/blob/main/docs/architecture/27-vector-tile-endpoints.md).
  contact:
    name: Motionworks AI
    url: https://mworks.com
    email: api@mworks.com

servers:
  - url: https://api.mworks.com/v2
    description: Production

security:
  - apiKey: []

tags:
  - name: Places
    description: POI-anchored places library — autocomplete, single POI lookup, list/filter, and brand footprint roll-up.
  - name: Tile Grants
    description: Mint, list, read, and revoke Places vector-tile delivery grants. JWT or X-API-Key authenticated.
  - name: Tile Discovery
    description: Public capability catalog — list the tilesets the Places product publishes (slugs, titles, zoom ranges, layer schemas). No auth required; free.
  - name: Tile TileJSON
    description: TileJSON capability URL — the discovery endpoint MapLibre calls to learn where to fetch tiles.
  - name: Tile Data
    description: Binary Mapbox Vector Tile bytes. Called by MapLibre/Mapbox GL on your behalf; you will not call this directly in normal use.

components:
  securitySchemes:
    apiKey:
      type: apiKey
      name: X-API-Key
      in: header
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        Supabase user JWT (`Authorization: Bearer <jwt>`). Used by
        portal/account flows that mint and manage tile grants for a
        logged-in user's organization.
    tileTokenAuth:
      type: apiKey
      in: query
      name: token
      description: >
        Opaque per-grant tile-token (24-hour TTL) minted by
        `GET /v2/places/tiles/grants/{id}/tilejson`. Accepted ONLY as
        the `?token=` query parameter — header form is rejected. Treat
        as a short-lived bearer credential.

  schemas:
    Brand:
      type: object
      description: >
        Brand-level footprint roll-up backed by the `brand_rollup`
        materialized view (ADR-024 amendment, app-mworks-com#156). v1
        keys on the lowercased brand display name (`brand_name_lower`);
        first-class `brand_id` column + chain matching are reserved for v2.
      x-motionworks-status: production
      x-motionworks-source: places-library
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      properties:
        brand_id:
          type: string
          description: >
            v1 brand identifier — the lowercased brand display name
            (`brand_rollup.brand_name_lower` PK). URL-encode reserved
            characters in the path parameter. A first-class
            non-name-derived `brand_id` is reserved for v2 (see
            app-mworks-com#147).
          example: starbucks
        name:
          type: string
          nullable: true
          description: Display form of the brand name.
        poi_count:
          type: integer
          nullable: true
          description: Total POIs in the brand footprint.
        measured_poi_count:
          type: integer
          nullable: true
          description: POIs that map to a Motionworks measured place.
        distinct_measured_places:
          type: integer
          nullable: true
          description: Distinct measured_place_id values across the brand's POIs.
        top_states:
          type: array
          maxItems: 10
          items:
            type: object
            required: [id, name, count]
            properties:
              id:
                type: string
                description: USPS state code (e.g. "CA").
              name:
                type: string
                description: Full state name (e.g. "California").
              count:
                type: integer
                nullable: true
                description: POIs in this state for this brand.
            additionalProperties: false
          description: >
            Up to 10 highest-POI states for this brand, count-desc. Each
            entry is `{id, name, count}` where `id` is the USPS code
            (e.g. `"CA"`), `name` is the full state name, and `count`
            is the POIs in that state for this brand. `count` MAY be
            null on environments served by a worker that has not yet
            picked up the post-0036 shape (legacy fallback).
        top_dmas:
          type: array
          maxItems: 10
          items:
            type: object
            required: [id, name, count]
            properties:
              id:
                type: string
                description: Nielsen DMA geoid (e.g. "803").
              name:
                type: string
                description: Human-readable DMA name (e.g. "Los Angeles").
              count:
                type: integer
                nullable: true
                description: POIs in this DMA for this brand.
            additionalProperties: false
          description: >
            Up to 10 highest-POI DMAs (Nielsen Designated Market Areas)
            for this brand, count-desc. Each entry is `{id, name, count}`
            where `id` is the `dma_geoid` (e.g. `"803"`), `name` is the
            human-readable DMA name, and `count` is the POIs in that
            DMA for this brand. Rural-only brands and the pre-0036
            legacy fallback emit `[]` (never null, never omitted).
        centroid:
          type: object
          nullable: true
          description: Spatial centroid of the brand footprint.
          properties:
            lat:
              type: number
              format: double
            lon:
              type: number
              format: double
        parent_brand:
          type: string
          nullable: true
          description: >
            Reserved for v2 (parent brand / chain hierarchy). Always null
            in v1.
        chain_id:
          type: string
          nullable: true
          description: >
            Reserved for v2 (canonical chain identifier). Always null
            in v1.
        footprint_polygon:
          type: object
          nullable: true
          description: >
            Reserved for v2 (GeoJSON polygon of the brand footprint).
            Always null in v1.
        sample_poi_id:
          type: string
          nullable: true
          description: One POI id from the brand, useful for spot-checks.
        h3_counts:
          type: array
          maxItems: 256
          items:
            $ref: '#/components/schemas/H3CellEntry'
          description: >
            Top-256 H3 res2 cells per brand (migration 0037). The
            free-path res2 drill, read from the precomputed
            `brand_rollup.h3_counts` jsonb column. Always present,
            may be empty. Each entry is `{id, count}` — H3 cells have
            no human name source, so there is no `name` dimension on
            the entry (different from `top_states` / `top_dmas`).
            Rural-only brands, pre-0037 environments, and snapshots
            rebuilt before the loader's H3 binding ran emit `[]`.
        h3_drilldown:
          $ref: '#/components/schemas/H3Drilldown'
          description: >
            Optional hierarchical H3 drill-down (migration 0037).
            Present ONLY when the caller passes the `h3_res` query
            parameter; absent otherwise.
      required:
        # The handler always emits every key (nullable-valued where the
        # data is absent), so the wire contract is keys-present. This
        # matches the Zod contract in tests/contract/places.schema.ts.
        - brand_id
        - name
        - poi_count
        - measured_poi_count
        - distinct_measured_places
        - top_states
        - top_dmas
        - h3_counts
        - centroid
        - parent_brand
        - chain_id
        - footprint_polygon
        - sample_poi_id

    H3CellEntry:
      type: object
      description: >
        Per-entry shape for `h3_counts` and `h3_drilldown.cells`. H3
        cells have no human name source, so the shape is `{id, count}`
        only (not `{id, name, count}` like top_states / top_dmas).
      required: [id, count]
      additionalProperties: false
      properties:
        id:
          type: string
          description: H3 cell id (15-character hex index).
          example: 82268ffffffffff
        count:
          type: integer
          minimum: 0
          description: POI count in this cell for this brand.

    H3Drilldown:
      type: object
      description: >
        Hierarchical H3 cell drill-down result. For `resolution = 2`
        the cells come from the precomputed `brand_rollup.h3_counts`
        MV column (free path); for `resolution` in 3..8 the cells
        come from a live aggregation against `places_universe.h3_r{N}`
        filtered by the validated parent cells. Same 1-credit cost as
        the base brand-detail call.
      required: [resolution, cells]
      additionalProperties: false
      properties:
        resolution:
          type: integer
          minimum: 2
          maximum: 8
          description: H3 resolution of the cells in the result.
        cells:
          type: array
          items:
            $ref: '#/components/schemas/H3CellEntry'
          description: Cells at `resolution`, count-desc.

    Place:
      type: object
      x-motionworks-status: production
      x-motionworks-source: places-library
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      properties:
        poi_id:
          type: string
          description: >
            Stable POI surrogate. Form `<source_prefix>_<poi_native_id>`,
            e.g. `dp_a1b2c3` for DATAPLOR. Survives monthly snapshot rollovers
            as long as the upstream source preserves its native id.
          example: dp_a1b2c3
        poi_native_id:
          type: string
          description: Upstream POI native id (e.g. DATAPLOR poi_place_id).
        poi_source:
          type: string
          description: Upstream POI source name (e.g. `dataplor`).
        name:
          type: string
          nullable: true
        name_lower:
          type: string
          nullable: true
          description: Generated lowercase form of name (for prefix lookup).
        poi_category:
          type: string
          nullable: true
        naics_category:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        st_usps:
          type: string
          minLength: 2
          maxLength: 2
          nullable: true
        st_name:
          type: string
          nullable: true
        co_geoid:
          type: string
          nullable: true
        cbsa_geoid:
          type: string
          nullable: true
        dma_geoid:
          type: string
          nullable: true
        dma_name:
          type: string
          nullable: true

        is_mapped_to_measured:
          type: boolean
          description: >
            True when this POI has been mapped to a Motionworks measured
            place. Cheap planner predicate; avoids dereferencing
            measured_place_id.
        measured_place_id:
          type: string
          nullable: true
          description: >
            Motionworks-internal measured-place id when mapped. Multiple POIs
            can share the same measured_place_id — intentional signal.
        measured_short_name:
          type: string
          nullable: true
        place_type_id_v2:
          type: integer
          nullable: true
        place_type:
          type: string
          nullable: true
        sub_type:
          type: string
          nullable: true
        measured_parent_place_id:
          type: string
          nullable: true
        measured_audit_status:
          type: string
          nullable: true
        measured_is_focused:
          type: boolean
          nullable: true
        market_id:
          type: string
          nullable: true
        poi_count_for_measured_place:
          type: integer
          nullable: true
          description: >
            Count of POIs that share this measured_place_id. > 1 surfaces the
            "multiple POIs in one measured place" measurement-quality signal.

        # match_type / match_name_score / match_dist_meters exist on the
        # places_universe table but are intentionally NOT projected to the
        # public API — they are operator-debug fields for the upstream
        # mapping pipeline. Wire them through a future /admin/ endpoint if
        # ever needed.

        lat:
          type: number
          format: double
          nullable: true
        lon:
          type: number
          format: double
          nullable: true

    AutocompleteResult:
      type: object
      description: >
        One row in the autocomplete response. Three kinds:
        `brand` (brand prefix hit from brand_rollup), `place` (POI prefix hit
        from places_universe), `brand_fuzzy` (trigram-similarity brand
        fallback when no prefix matches).
      x-motionworks-status: production
      properties:
        kind:
          type: string
          enum: [brand, place, brand_fuzzy]
        name:
          type: string
        poi_id:
          type: string
          nullable: true
          description: Populated for `kind=place`; null for brand kinds.
        place_type:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        st_usps:
          type: string
          nullable: true
        poi_count:
          type: integer
          nullable: true
          description: Populated for brand kinds; null for `kind=place`.
        match_score:
          type: number
          format: double
          nullable: true
          description: >
            For `brand_fuzzy`, the trigram similarity in [0,1]. For prefix
            kinds (`brand`, `place`), always 1.0.
        match_highlights:
          type: array
          items:
            type: object
            required: [field, start, end]
            additionalProperties: false
            properties:
              field:
                type: string
                enum: [name]
                description: >
                  The field of the autocomplete row this span refers to.
                  v1 only emits highlights for `name`.
              start:
                type: integer
                minimum: 0
                description: >
                  Inclusive JavaScript character offset into the `name`
                  field. NOT a UTF-8 byte offset — Unicode-safe across
                  accented brand names.
              end:
                type: integer
                minimum: 0
                description: >
                  Exclusive JavaScript character offset (start ≤ end ≤ name.length).
          description: >
            Matched-substring spans inside the `name` field. The UI bolds
            these character ranges. Empty array for `kind=brand_fuzzy`
            (no exact substring; client falls back to no-bold) and when
            the query does not appear inside `name`. Always present;
            never null. ADR-024 §Surfaces.

    Pagination:
      type: object
      properties:
        cursor:
          type: string
          nullable: true
          description: >
            Opaque cursor (currently the last `poi_id` in the page). Pass
            it as `?cursor=...` on the next request.
        has_more:
          type: boolean

    Provenance:
      type: object
      description: Response-level provenance (see TF-93).
      # Invariant F2 — is_focused <=> measurement_method (ADR-033 §2.3).
      # JSON Schema 2020-12 conditional (OpenAPI 3.1 is a 2020-12 dialect).
      # Both fields stay OPTIONAL and independently omittable: the `if`
      # only fires when BOTH are present, so an envelope carrying only
      # `is_focused`, only `measurement_method`, or neither still validates
      # — which keeps every non-Placecast product's existing envelope valid.
      # Only the disagreeing combination is unrepresentable. Mirrors the
      # .refine() guard on ProvenanceSchema in
      # packages/types/src/schemas/provenance.ts.
      if:
        required: [is_focused, measurement_method]
      then:
        oneOf:
          - properties:
              is_focused:
                const: true
              measurement_method:
                const: direct
          - properties:
              is_focused:
                const: false
              measurement_method:
                const: estimated
      properties:
        source:
          type: string
        source_doc:
          type: string
          format: uri
        methodology_version:
          type: string
        data_vintage:
          type: string
          format: date
        data_freshness:
          type: string
          enum: [hourly, daily, weekly, monthly, annually, on-demand, static]
        data_latency_days:
          type: integer
        data_maturity:
          type: string
          enum: [production, research-preview, synthetic-only, roadmap]
        is_focused:
          type: boolean
        measurement_method:
          type: string
          enum: [direct, estimated]
          description: >
            Optional explicit measurement provenance. "direct" means the
            place is focused and its statistics are measured; "estimated"
            means the place is unfocused and its statistics use peer-group
            inference. When both this field and is_focused are present they
            must agree (is_focused=true => direct, false => estimated).

    Meta:
      type: object
      properties:
        request_id:
          type: string
        credits_used:
          type: integer
        credits_remaining:
          type: integer
        product:
          type: string
        operation_id:
          type: string
        version:
          type: string
        provenance:
          $ref: '#/components/schemas/Provenance'

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            status:
              type: integer
            request_id:
              type: string
            product:
              type: string
            docs_url:
              type: string
            context:
              type: object
              additionalProperties: true

    TileMeta:
      type: object
      description: >-
        Response envelope metadata emitted by the Places vector-tile
        control-plane endpoints (grant CRUD + TileJSON). Distinct from
        the JSON `Meta` schema above — tile control-plane responses
        intentionally do NOT carry `provenance` (TF-93) because they are
        a delivery / control surface, not a data surface.
      required: [request_id, credits_used, product]
      properties:
        request_id:
          type: string
        credits_used:
          type: integer
        product:
          type: string
          enum: [places]

    TilesetCatalogLayer:
      type: object
      x-motionworks-status: production
      description: >
        TileJSON 3.0.0 `vector_layers[]` entry — one per MVT layer
        emitted by the upstream tileset.
      required: [id, fields]
      properties:
        id:
          type: string
          description: MVT-side layer id (matches the layer name CARTO emits inside the tile bytes — often `default` for single-layer tilesets, NOT the URL slug).
        description:
          type: string
        minzoom:
          type: integer
          minimum: 0
        maxzoom:
          type: integer
          minimum: 0
        fields:
          type: object
          description: Map of MVT feature property → TileJSON field type (`String` | `Number` | `Boolean`).
          additionalProperties:
            type: string
            enum: [String, Number, Boolean]

    TilesetCatalogEntry:
      type: object
      x-motionworks-status: production
      required: [slug, title, minzoom, maxzoom, bounds, layers]
      properties:
        slug:
          type: string
          description: Customer-facing layer slug. Matches the `{layer}` URL path segment used in `GET /v2/places/tiles/{layer}/{z}/{x}/{y}.mvt` and the values accepted in `TileGrantCreateRequest.tilesets`.
        title:
          type: string
          description: Human-readable display title for this tileset.
        minzoom:
          type: integer
          minimum: 0
        maxzoom:
          type: integer
          minimum: 0
        bounds:
          type: array
          description: TileJSON `bounds` — `[west, south, east, north]`, WGS84.
          items:
            type: number
          minItems: 4
          maxItems: 4
        layers:
          type: array
          items:
            $ref: '#/components/schemas/TilesetCatalogLayer'

    TilesetCatalogResponse:
      type: object
      x-motionworks-status: production
      required: [tilesets]
      properties:
        tilesets:
          type: array
          items:
            $ref: '#/components/schemas/TilesetCatalogEntry'

    TileGrantCreateRequest:
      type: object
      x-motionworks-status: production
      required: [name, tilesets, allowed_origins, expires_at]
      properties:
        name:
          type: string
          maxLength: 120
          description: Human-readable label for this grant (shown in the developer dashboard).
        tilesets:
          type: array
          minItems: 1
          items:
            type: string
            enum: [points, poi_points]
          description: >
            Tileset slugs to include in this grant. Places publishes
            two layers today: `points` (measured-place points) and
            `poi_points` (POI Universe). Any other slug is rejected with
            403 `TILE_LAYER_NOT_LICENSED`.
        allowed_origins:
          type: array
          items:
            type: string
          description: >
            Browser `Origin` allowlist for `/tilejson` and `.mvt`
            fetches. Use the explicit string `"null"` to allow
            requests that omit `Origin` (e.g. `curl`, server-side
            rendering). Wildcards are NOT supported — list each
            origin literally.
        expires_at:
          type: string
          format: date-time
          description: >
            ISO 8601 timestamp at which this grant expires. The
            tile-token JWT minted by `/tilejson` independently expires
            24h after each mint — re-fetch TileJSON before then.

    TileGrant:
      type: object
      x-motionworks-status: production
      description: >
        Server representation of a Places tile-delivery grant. The
        `grant_id` is a Crockford-base32 ULID and is itself a bearer
        credential — anyone who knows the ULID can fetch the TileJSON
        capability URL from an allowed origin.
      properties:
        grant_id:
          type: string
          description: Opaque ULID. Treat as a secret.
        org_id:
          type: string
          format: uuid
        product:
          type: string
          enum: [places]
        name:
          type: string
        tilesets:
          type: array
          items:
            type: string
            enum: [points, poi_points]
        allowed_origins:
          type: array
          items:
            type: string
        expires_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        created_by:
          type: string
          format: uuid
          nullable: true
        usage_30d:
          type: integer
          description: 30-day tile fetch count (placeholder — outbox aggregate lands in a follow-up; reported as 0 today).

    TileGrantCreateResponse:
      type: object
      x-motionworks-status: production
      properties:
        grant_id:
          type: string
          description: Opaque ULID. Treat as a secret.
        tile_json_url:
          type: string
          format: uri
          description: >
            Capability URL — drop this into MapLibre as the
            `url` for a vector source. The opaque `grant_id` segment
            IS the credential, so treat the URL like an AWS S3
            presigned URL.
        tilesets:
          type: array
          items:
            type: string
        allowed_origins:
          type: array
          items:
            type: string
        expires_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time

    TileJsonManifest:
      type: object
      x-motionworks-status: production
      description: >
        TileJSON 3.0.0 manifest with a freshly-minted 24-hour tile-token
        embedded in `tiles[]`. Standard TileJSON consumers (MapLibre,
        Mapbox GL JS, deck.gl `MVTLayer`) handle the rest transparently.
      properties:
        tilejson:
          type: string
          example: 3.0.0
        name:
          type: string
        tiles:
          type: array
          items:
            type: string
            format: uri
        minzoom:
          type: integer
        maxzoom:
          type: integer
        bounds:
          type: array
          items:
            type: number
          minItems: 4
          maxItems: 4
        attribution:
          type: string
        vector_layers:
          type: array
          items:
            type: object
        x-mw:
          type: object
          description: Motionworks-specific tile-token refresh metadata.
          properties:
            grant_id:
              type: string
            product:
              type: string
              enum: [places]
            tilesets:
              type: array
              items:
                type: string
            token_expires_at:
              type: string
              format: date-time
            refresh_after:
              type: integer
              description: Seconds-since-mint after which clients should re-fetch this TileJSON. Set to 82,800 (23h, one hour before the tile-token JWT expires).

paths:
  /places/autocomplete:
    get:
      operationId: placesAutocomplete
      summary: Keystroke autocomplete (brand-first, then POI prefix, then fuzzy fallback)
      x-credit-cost: 0
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      x-motionworks-snapshot-header: X-MW-Places-Snapshot
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
          description: Query prefix (case-insensitive; lowercased server-side).
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 20
            default: 10
        - name: near
          in: query
          schema:
            type: string
          description: 'Optional "lon,lat" hint. v1 does not change the query plan; reserved for future spatial boost.'
      responses:
        '200':
          description: Autocomplete results
          headers:
            X-MW-Places-Snapshot:
              schema:
                type: string
                pattern: '^[0-9]{8}$'
              description: Snapshot date (YYYYMMDD) the response was served from.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/AutocompleteResult'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Internal error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /places/brands/{brand_id}:
    get:
      operationId: getPlacesBrand
      summary: Get brand footprint by `brand_id`
      x-credit-cost: 1
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      x-motionworks-snapshot-header: X-MW-Places-Snapshot
      description: >
        Returns the brand-level footprint rollup (POI count, top states +
        cities, centroid, sample POI) backed by the `brand_rollup`
        materialized view. v1 `brand_id` = `brand_name_lower` (lowercased
        display name; URL-encode reserved characters). `parent_brand`,
        `chain_id`, and `footprint_polygon` are reserved for v2 and
        always return null.
      parameters:
        - name: brand_id
          in: path
          required: true
          schema:
            type: string
          description: Lowercased brand name (e.g. `starbucks`, `taco%20bell`).
        - name: h3_res
          in: query
          required: false
          schema:
            type: integer
            minimum: 2
            maximum: 8
          description: >
            H3 target resolution for the hierarchical drill-down
            (migration 0037). When set, the response carries an
            `h3_drilldown` block with cells at this resolution. For
            `h3_res = 2` the cells are served from the precomputed
            `brand_rollup.h3_counts` MV column (free path); for
            `h3_res` in 3..8 the cells come from a live aggregation
            against `places_universe.h3_r{N}` filtered by `h3_cells`.
            Same 1-credit cost regardless of resolution.
        - name: h3_cells
          in: query
          required: false
          schema:
            type: string
          description: >
            Comma-separated list of PARENT H3 cell ids (at resolution
            `h3_res - 1`). REQUIRED when `h3_res >= 3`; IGNORED when
            `h3_res = 2`. Maximum 64 parent cells per call; every cell
            must be a valid H3 index at resolution `h3_res - 1`.
      responses:
        '200':
          description: Brand footprint
          headers:
            X-MW-Places-Snapshot:
              schema:
                type: string
                pattern: '^[0-9]{8}$'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Brand'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Invalid or missing API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Brand not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Internal error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /places/{poi_id}:
    get:
      operationId: getPlace
      summary: Get a single POI by `poi_id`
      x-credit-cost: 1
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      x-motionworks-snapshot-header: X-MW-Places-Snapshot
      parameters:
        - name: poi_id
          in: path
          required: true
          schema:
            type: string
          description: Stable POI surrogate, e.g. `dp_a1b2c3`.
      responses:
        '200':
          description: Place record
          headers:
            X-MW-Places-Snapshot:
              schema:
                type: string
                pattern: '^[0-9]{8}$'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Place'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          description: Invalid or missing API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: POI not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Rate limit exceeded

  /places:
    get:
      operationId: searchPlaces
      summary: List / filter POIs
      x-credit-cost: 1
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      x-motionworks-snapshot-header: X-MW-Places-Snapshot
      parameters:
        - name: q
          in: query
          description: >
            Weighted full-text search (uses the generated `search_tsv`
            column; `simple` dictionary preserves brand names).
          schema:
            type: string
        - name: bbox
          in: query
          schema:
            type: string
          description: 'Bounding box "w,s,e,n" (lon,lat,lon,lat) in EPSG:4326. Filters results to the envelope, and also drives KNN ordering of results by distance to the envelope centroid (unless `near` is also given, which takes ordering precedence).'
        - name: near
          in: query
          schema:
            type: string
          description: 'Anchor point "lon,lat". When combined with `radius_m`, filters; also drives KNN ordering of results.'
        - name: radius_m
          in: query
          schema:
            type: number
            minimum: 0
          description: Radius in meters for `near` filter (uses `ST_DWithin` on geography).
        - name: place_type_id_v2
          in: query
          schema:
            type: integer
        - name: st_usps
          in: query
          schema:
            type: string
            minLength: 2
            maxLength: 2
        - name: dma_geoid
          in: query
          schema:
            type: string
        - name: is_mapped_to_measured
          in: query
          schema:
            type: boolean
        - name: cursor
          in: query
          schema:
            type: string
          description: >
            Opaque cursor from a previous response's `pagination.cursor`.
            Stable, skip/dupe-free pagination holds only for non-spatial
            (filter-only) queries. Spatially-ordered queries (`near` or
            `bbox`) use a `poi_id` keyset that does not match the distance
            ordering, so paging them may skip or revisit rows (tracked in
            api#409).
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        '200':
          description: Paginated place records
          headers:
            X-MW-Places-Snapshot:
              schema:
                type: string
                pattern: '^[0-9]{8}$'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Place'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ─── Tile Discovery (public capability catalog) ──────────────────────

  /places/tiles/tilesets:
    get:
      tags: [Tile Discovery]
      operationId: listPlacesTilesets
      summary: List the tilesets Places publishes
      description: >
        Returns the customer-facing tile catalog for the Places product —
        every layer slug, human-readable title, zoom range, geographic
        bounds, and TileJSON `vector_layers[]` schema. Use this to pick
        a `tilesets[]` slug before minting a grant via
        `POST /v2/places/tiles/grants`, or to render a "supported layers"
        UI without hardcoding the catalog client-side.


        Public — no auth header required. Free — 0 credits. Response
        carries `Cache-Control: private, no-store` (matches the router's
        global egress policy on `api2.mworks.com`, which overwrites
        upstream cache headers — same posture as
        `/v2/places/autocomplete`).
      x-credit-cost: 0
      x-motionworks-status: production
      security: []
      responses:
        '200':
          description: Tile catalog.
          headers:
            Cache-Control:
              schema:
                type: string
                example: private, no-store
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TilesetCatalogResponse'
                  meta:
                    $ref: '#/components/schemas/TileMeta'

  # ─── Tile Grants (control plane) ─────────────────────────────────────

  /places/tiles/grants:
    post:
      tags: [Tile Grants]
      operationId: createPlacesTileGrant
      summary: Mint a Places vector-tile grant
      description: >
        Creates a new tile-delivery grant scoped to the caller's org and
        returns a capability `tile_json_url`. Drop the URL into MapLibre
        or Mapbox GL JS to start rendering. Free — control-plane
        endpoint, 0 credits.


        Auth: Supabase JWT (`Authorization: Bearer <jwt>`) OR org-scoped
        API key (`X-API-Key: mw_…`). Anonymous callers cannot mint
        grants. The caller's organization must be metered + licensed for
        Places vector tiles; credit-pack-only orgs receive
        `403 TILE_GRANTS_REQUIRE_METERED`.
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
        - apiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TileGrantCreateRequest'
      responses:
        '201':
          description: Grant created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TileGrantCreateResponse'
                  meta:
                    $ref: '#/components/schemas/TileMeta'
        '400':
          description: Invalid request body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Missing or invalid credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >
            One of three cases:
              * `FEATURE_NOT_LICENSED` — caller's org is not licensed
                for Places vector tiles (`placecast.places.poi_place_tiles`).
              * `TILE_GRANTS_REQUIRE_METERED` — caller's org has no
                metered Stripe billing rail (credit-pack only). Contact
                sales to enable Places vector-tile delivery.
              * `TILE_LAYER_NOT_LICENSED` — one or more `tilesets[]`
                entries name a layer Places does not publish.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                tile_layer_not_licensed:
                  summary: A tileset slug is not published by Places
                  value:
                    error:
                      code: TILE_LAYER_NOT_LICENSED
                      message: One or more tilesets are not published by Places.
                      status: 403
                      request_id: req_abc123
                      product: places
                      context:
                        missing: [made_up_layer]
                        supported: [points, poi_points]

    get:
      tags: [Tile Grants]
      operationId: listPlacesTileGrants
      summary: List active grants for the caller's org
      description: >
        Returns the caller-org's Places tile grants, most recent first.
        Free — control-plane endpoint, 0 credits.
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        '200':
          description: Grant list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TileGrant'
                  meta:
                    $ref: '#/components/schemas/TileMeta'
        '401':
          description: Missing or invalid credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /places/tiles/grants/{id}:
    get:
      tags: [Tile Grants]
      operationId: getPlacesTileGrant
      summary: Read one grant
      description: >
        Returns a single grant by ULID, scoped to the caller's org.
        Free — control-plane endpoint, 0 credits.
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Grant ULID returned by `POST /v2/places/tiles/grants`.
      responses:
        '200':
          description: Grant.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TileGrant'
                  meta:
                    $ref: '#/components/schemas/TileMeta'
        '401':
          description: Missing or invalid credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Grant not found, expired, or belongs to a different org.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /places/tiles/grants/{id}/revoke:
    post:
      tags: [Tile Grants]
      operationId: revokePlacesTileGrant
      summary: Revoke a grant immediately
      description: >
        Marks the grant revoked. The 24-hour tile-token JWTs already
        minted by `/tilejson` will continue to validate until they
        expire; for an instant cutoff in production, rotate
        `MW_VECTOR_TILE_JWT_KEY` (operator runbook). Free — 0 credits.
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Grant revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/TileGrant'
                  meta:
                    $ref: '#/components/schemas/TileMeta'
        '401':
          description: Missing or invalid credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Grant not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ─── TileJSON (capability URL) ───────────────────────────────────────

  /places/tiles/grants/{id}/tilejson:
    get:
      tags: [Tile TileJSON]
      operationId: getPlacesTileJson
      summary: TileJSON manifest for a grant (capability URL — no auth header)
      description: >
        Returns a TileJSON 3.0.0 manifest with a freshly-minted 24-hour
        tile-token embedded in `tiles[]`. This is the URL you hand to
        MapLibre, Mapbox GL JS, or any TileJSON-aware client.


        ## This URL is a capability — treat it like a secret

        The TileJSON URL returned by `POST /v2/places/tiles/grants` is a
        **capability URL** — it carries the credentials needed to fetch
        tiles embedded in the URL itself. Treat it like an AWS S3
        presigned URL: anyone who has the URL can render your map until
        the underlying grant expires or is revoked. This is by design —
        your front-end JavaScript can pass it straight to MapLibre
        without a separate auth header. Two safety nets are built in:
        the grant's `allowed_origins` list pins which sites can fetch
        tiles (Origin-enforced), and
        `POST /v2/places/tiles/grants/{id}/revoke` kills the grant.


        Free — control-plane endpoint, 0 credits. The tile bytes
        themselves are metered on
        `GET .../{layer}/{z}/{x}/{y}.mvt`.
      x-credit-cost: 0
      x-motionworks-status: production
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Grant ULID. Opaque bearer credential — treat as secret.
      responses:
        '200':
          description: TileJSON 3.0.0 manifest.
          headers:
            Cache-Control:
              schema:
                type: string
                example: private, max-age=3600, must-revalidate
            Vary:
              schema:
                type: string
                example: Origin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TileJsonManifest'
        '403':
          description: >
            Origin not in the grant's `allowed_origins`, or the org is
            not licensed/metered for Places vector tiles
            (`FEATURE_NOT_LICENSED` / `TILE_GRANTS_REQUIRE_METERED` /
            `ORIGIN_NOT_ALLOWED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Grant not found, expired, or revoked.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ─── Tile Data (binary) ──────────────────────────────────────────────

  /places/tiles/{layer}/{z}/{x}/{y}.mvt:
    get:
      tags: [Tile Data]
      operationId: getPlacesTileMvt
      summary: Fetch a single Places vector tile (binary MVT)
      description: >
        **You will not call this endpoint directly in normal use.**
        MapLibre, Mapbox GL JS, and deck.gl's `MVTLayer` extract the
        `?token=` JWT from the TileJSON manifest above and append it to
        every tile request on your behalf.


        Returns a Mapbox Vector Tile (binary protobuf). Auth is the
        `?token=<opaque-jwt>` query parameter ONLY — header form is
        rejected by design (capability semantics). Metered at
        **1 credit per 1,000 tiles served** (op id `places_tiles_fetch`,
        meter divisor 1000). Response may be gzip-encoded — clients MUST
        honor `Content-Encoding`. Empty tiles (z/x/y with no features)
        return HTTP 204.


        See [ADR-027](https://github.com/InterMx/api-mworks-com/blob/main/docs/architecture/27-vector-tile-endpoints.md)
        for the full tile-delivery contract.
      x-credit-cost: 1
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/places-tiles
      security:
        - tileTokenAuth: []
      externalDocs:
        description: MapLibre vector source spec — explains how MapLibre fetches this endpoint for you.
        url: https://maplibre.org/maplibre-style-spec/sources/#vector
      parameters:
        - name: layer
          in: path
          required: true
          schema:
            type: string
            enum: [points, poi_points]
          description: >
            Tileset slug. Places publishes two layers: `points`
            (measured-place points) and `poi_points` (POI Universe).
        - name: z
          in: path
          required: true
          schema:
            type: integer
            minimum: 0
            maximum: 14
          description: Tile zoom level. Places layers are valid for z in [0, 14].
        - name: x
          in: path
          required: true
          schema:
            type: integer
            minimum: 0
          description: Tile column.
        - name: y
          in: path
          required: true
          schema:
            type: integer
            minimum: 0
          description: Tile row.
        - name: token
          in: query
          required: true
          schema:
            type: string
          description: Opaque tile-token JWT extracted from TileJSON. Treat as secret.
      responses:
        '200':
          description: Vector tile bytes.
          headers:
            Cache-Control:
              schema:
                type: string
                example: private, max-age=300
            Content-Encoding:
              schema:
                type: string
                example: gzip
              description: Present when upstream returned gzip-encoded bytes. Clients MUST honor.
            Vary:
              schema:
                type: string
                example: Accept-Encoding, Origin
            X-MW-Tileset:
              schema:
                type: string
              description: Resolved upstream tileset id (e.g. `places.points`).
            X-MW-Snapshot:
              schema:
                type: string
                format: date
              description: Tileset snapshot date.
          content:
            application/vnd.mapbox-vector-tile:
              schema:
                type: string
                format: binary
        '204':
          description: Empty tile — no features intersect this z/x/y. No body, no `Content-Encoding`.
        '400':
          description: >-
            `error.code: INVALID_REQUEST`. Zoom out of range
            (z must be in [0, 14] for Places layers) or non-integer
            `x`/`y`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: >-
            `error.code: UNAUTHORIZED`. Disambiguated by
            `error.context.reason`:
              * (no reason) — missing, invalid, or expired `?token=` tile-token JWT.
              * `layer_not_in_tilesets` — token does not authorize the requested layer.
              * `grant_revoked` — the backing grant has been revoked.
              * `tile_grant_exhausted` — the org's credit wallet is exhausted (billing signal, not auth).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >-
            Request `Origin` is not in the grant's `allowed_origins`
            (`error.code: ORIGIN_NOT_ALLOWED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown layer slug, or the backing grant was not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
