openapi: '3.1.0'
info:
  title: Motionworks API - Placecast (Foot Traffic)
  version: 2.1.0
  description: >
    Placecast — place intelligence (continuous foot-traffic profiles +
    on-demand event-based analysis). One product, four operations:

      GET  /v2/placecast/profiles/:place_id  — Full place profile object (visits, demographics, dwell, trade area, history) for one place.
      POST /v2/placecast/select              — Submit an on-demand event-based scenario for specific date-time windows.
      GET  /v2/placecast/select              — List submitted Select scenarios.
      GET  /v2/placecast/select/:id          — Poll a Select scenario for status + results.
      POST /v2/placecast/places              — Register a custom place for measurement.

    Sources:
      https://docs.mworks.com/docs/placecast-profiles
      https://docs.mworks.com/docs/placecast-select


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

    Mapbox Vector Tile (`.mvt`) delivery for Motionworks Placecast data —
    drop the building footprint and District Collection Area polygon
    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 `placecast_tiles_fetch`). The grant + TileJSON control-plane
    endpoints are free. **Grants require an explicit Placecast tile
    license grant** — the bundled feature key
    `placecast.profiles.building_dca_tiles` is manual-grant-only in the
    default access matrix, so orgs without an `org_feature_grants` row
    for that key receive `403 FEATURE_NOT_LICENSED` on grant-create and
    TileJSON. Credit-pack-only orgs also receive `403
    TILE_GRANTS_REQUIRE_METERED` until the metered Stripe rail is
    enabled.


    ### Workflow

    1. **Mint a grant** (`POST /v2/placecast/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/placecast/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/placecast/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, both gated by the
    SAME bundled entitlement):
    `buildings` (place-anchored building footprints) and `dca` (District
    Collection Area polygons).


    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: Placecast
    description: Place intelligence — continuous profiles and on-demand event analysis.
  - name: Tile Grants
    description: Mint, list, read, and revoke Placecast vector-tile delivery grants. JWT or X-API-Key authenticated.
  - name: Tile Discovery
    description: Public capability catalog — list the tilesets the Placecast 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/placecast/tiles/grants/{id}/tilejson`. Accepted ONLY as
        the `?token=` query parameter — header form is rejected. Treat
        as a short-lived bearer credential.

  schemas:
    VisitsCi:
      type: array
      prefixItems:
        - type: integer
          description: Lower bound.
        - type: integer
          description: Upper bound.
      items:
        type: integer
      minItems: 2
      maxItems: 2
      description: >
        90% confidence interval [lower, upper]. Derived from peer-group
        empirical quantiles. Not user-configurable. Exactly two integers —
        a fixed 2-tuple, mirroring VisitsCiSchema (z.tuple) in
        packages/types/src/schemas/placecast.ts.

    PeerGroup:
      type: object
      description: >
        Peer group metadata. Direct = place has direct measurement;
        Fallback = stats inferred via peer-group inference.
      required: [size, level]
      properties:
        size:
          type: integer
          minimum: 0
          description: Number of places in the peer group.
        level:
          type: string
          enum: [direct, fallback]
          description: >
            "direct" = direct measurement available;
            "fallback" = peer-group inference only.

    PlacecastReliability:
      type: object
      description: >
        4-score data-quality assessment (replaces the invented A/B/C/D/U
        enum from the v2 scaffold). Each score is 0–1 where 0 = behaves
        as expected vs peer group, 1 = significant outlier.
        is_focused = false means stats are estimated rather than measured.

        **View-derivation only** (not returned by API): letter grade
        mapping. Precedence-ordered — evaluate top to bottom, first match
        wins, where max_score = max of the four scores. The rows are
        mutually exclusive (is_focused splits rows 1-2 from 3-6, and the
        score bands inside each branch do not overlap):
        1. U = !is_focused AND max_score >= 0.9;
        2. D = !is_focused (max_score < 0.9);
        3. A = is_focused AND max_score < 0.3;
        4. B = is_focused AND 0.3 <= max_score < 0.5;
        5. C = is_focused AND 0.5 <= max_score < 0.7;
        6. D = is_focused AND max_score >= 0.7.
        Unfocused places never grade above D; U is unfocused-only (a
        focused place with max_score >= 0.9 grades D, never U). See
        ADR-033 §3 for the normative table.

        Invariant F1 (ADR-033 §2.2), machine-enforced by the
        `if`/`then`/`else` block below, not merely documented: is_focused
        and peer_group.level encode the same fact at two granularities, so
        is_focused=false requires peer_group.level='fallback' and
        is_focused=true requires 'direct'. A payload where they disagree
        is a producer bug and is rejected in both directions, mirroring
        the two `.refine()` guards on PlacecastReliabilitySchema in
        packages/types/src/schemas/placecast.ts.
      x-motionworks-status: production
      x-motionworks-source: placecast-validation
      x-motionworks-source-doc: https://docs.mworks.com/docs/placecast-validation
      required:
        [
          placecast_type,
          visit_score,
          dwell_score,
          profile_score,
          visitor_distance_score,
          is_focused,
          peer_group,
          methodology_version,
        ]
      # Invariant F1 — is_focused <=> peer_group.level (ADR-033 §2.2).
      # JSON Schema 2020-12 conditional (OpenAPI 3.1 is a 2020-12 dialect).
      # `is_focused` is required above, so the `else` branch is reached only
      # for is_focused=false — encoding both directions of the biconditional:
      #   is_focused=true  => peer_group.level must be 'direct'
      #   is_focused=false => peer_group.level must be 'fallback'
      if:
        properties:
          is_focused:
            const: true
      then:
        properties:
          peer_group:
            properties:
              level:
                const: direct
      else:
        properties:
          peer_group:
            properties:
              level:
                const: fallback
      properties:
        placecast_type:
          type: string
          minLength: 1
          description: >
            Placecast validation cohort used to interpret the reliability
            scores, e.g. "transit_station", "amusement_park", or
            "sitdown_restaurant". Required non-empty string — mirrors
            z.string().min(1) in the Zod source of truth. The cohort
            universe is deliberately NOT closed to an enum (ADR-033 §6).
        visit_score:
          type: number
          minimum: 0
          maximum: 1
          description: Visits per sqft vs peer group. Low = expected.
        dwell_score:
          type: number
          minimum: 0
          maximum: 1
          description: Avg dwell vs peer group.
        profile_score:
          type: number
          minimum: 0
          maximum: 1
          description: Shape of hourly-visit pattern vs peer group.
        visitor_distance_score:
          type: number
          minimum: 0
          maximum: 1
          description: Typical home→place distance vs peer group.
        is_focused:
          type: boolean
          description: >
            When false, stats are estimated via peer-group inference
            (the "unfocused" methodology) rather than measured directly.
        peer_group:
          $ref: '#/components/schemas/PeerGroup'
        methodology_version:
          type: string
          description: >
            Placecast methodology version, e.g. "v2.3". Frontends should
            use this to determine how to interpret scores.

    PlacecastProfile:
      type: object
      x-motionworks-status: production
      x-motionworks-source: placecast-profiles
      x-motionworks-source-doc: https://docs.mworks.com/docs/placecast-profiles
      description: >
        One row per published_date per place. A 6-week rolling average
        published each Monday on a 15-day latency.

        The `required` list below mirrors `PlacecastProfileSchema` in
        `packages/types/src/schemas/placecast.ts` field-for-field — Zod is
        the source of truth, this spec mirrors it, and
        `packages/types/src/generated/placecast.d.ts` is derived from this
        spec. A parity test fails typecheck if a required/optional flag
        drifts between the three layers.
      required:
        [
          place_id,
          name,
          place_type_id,
          place_type,
          place_audit_status,
          place_modified_date,
          published_date,
          visits,
          dropoffs,
          activities,
          visits_ci,
          visits_observations,
          imputed,
          visits_avg_dwell,
          activities_dwell_threshold,
          visits_frequency_per_person,
          visits_unique_persons,
          reliability,
        ]
      properties:
        place_id:
          type: integer
        name:
          type: string
        place_type_id:
          type: integer
        place_type:
          type: string
        place_audit_status:
          type: string
        place_modified_date:
          type: string
          format: date
        published_date:
          type: string
          format: date
          description: Always a Monday. 6-week rolling averages published weekly.
        visits:
          type: integer
          description: >
            Weekly visits — ROLLING AVERAGE, not daily. This is the 6-week
            rolling mean, published each Monday on a 15-day latency.
        dropoffs:
          type: integer
        activities:
          type: integer
          description: activities = visits + dropoffs
        passbys:
          type: integer
        stays:
          type: integer
        visits_ci:
          $ref: '#/components/schemas/VisitsCi'
        visits_observations:
          type: integer
        imputed:
          type: boolean
        visits_avg_dwell:
          type: number
          description: Average dwell in minutes (ignores residents + workers).
        activities_dwell_threshold:
          type: number
        visits_frequency_per_person:
          type: number
        visits_unique_persons:
          type: integer
        visits_unique_long_trips:
          type: integer
        city:
          type: string
        state:
          type: string
        lat:
          type: number
        lon:
          type: number
        reliability:
          $ref: '#/components/schemas/PlacecastReliability'

    PlacecastSelectRequest:
      type: object
      x-motionworks-status: production
      x-motionworks-source: placecast-select
      x-motionworks-source-doc: https://docs.mworks.com/docs/placecast-select
      required: [name, place_ids, start_datetime, end_datetime]
      properties:
        name:
          type: string
        place_ids:
          type: array
          minItems: 1
          items:
            type: integer
          description: One or more Motionworks place IDs.
        start_datetime:
          type: string
          description: ISO 8601 in the place's local timezone.
        end_datetime:
          type: string
        segment_ids:
          type: array
          items:
            type: string
          description: Optional Motionworks segment IDs to force inclusion.
        custom_dwell_threshold:
          type: number
          minimum: 2
          maximum: 30
          description: Custom dwell threshold in minutes. Defaults to the place's native threshold.

    PlacecastSelection:
      type: object
      properties:
        selection_id:
          type: string
        name:
          type: string
        status:
          type: string
          enum: [pending, processing, complete, failed]
        place_ids:
          type: array
          items:
            type: integer
        start_datetime:
          type: string
        end_datetime:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        # Result fields (populated when status=complete)
        activities:
          type: integer
          nullable: true
        visits:
          type: integer
          nullable: true
        dropoffs:
          type: integer
          nullable: true
        passbys:
          type: integer
          nullable: true
        stays:
          type: integer
          nullable: true
        visits_ci:
          $ref: '#/components/schemas/VisitsCi'
          nullable: true
        visits_observations:
          type: integer
          nullable: true
        imputed:
          type: boolean
          nullable: true
        visits_avg_dwell:
          type: number
          nullable: true
        visits_unique_persons:
          type: integer
          nullable: true
        error:
          type: string
          nullable: true

    Pagination:
      type: object
      properties:
        cursor:
          type: string
          nullable: true
        has_more:
          type: boolean
        total:
          type: integer

    Provenance:
      type: object
      # 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
        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
        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
            docs_url:
              type: string

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

    TileMeta:
      type: object
      x-motionworks-status: production
      properties:
        request_id:
          type: string
        credits_used:
          type: integer
          enum: [0]
        product:
          type: string
          enum: [placecast]

    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/placecast/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: [buildings, dca]
          description: >
            Tileset slugs to include in this grant. Placecast publishes
            two layers today, both gated by the same bundled
            `placecast.profiles.building_dca_tiles` entitlement:
            `buildings` (place-anchored building footprints) and `dca`
            (District Collection Area polygons). 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 Placecast 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: [placecast]
        name:
          type: string
        tilesets:
          type: array
          items:
            type: string
            enum: [buildings, dca]
        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: [placecast]
            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:
  # ─── Placecast Profiles (weekly rolling) ─────────────────────────────

  /placecast/profiles/{place_id}:
    get:
      operationId: getPlacecastProfile
      summary: Weekly rolling visit profile for a place
      description: >
        Returns the 6-week rolling average published_date report for a place.
        visits is weekly, not daily. visits_ci is a 90% CI.
        Use `published_date` param to retrieve a specific week's publication.
      x-credit-cost: 20
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/placecast-profiles
      parameters:
        - name: place_id
          in: path
          required: true
          schema:
            type: integer
        - name: published_date
          in: query
          schema:
            type: string
            format: date
          description: Defaults to the most recent Monday publication.
      responses:
        '200':
          description: Placecast Profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PlacecastProfile'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '404':
          description: Place not found


  /placecast/select:
    post:
      operationId: createPlacecastSelection
      summary: Create an on-demand visit analysis for specific date-time windows
      x-credit-cost: 2000
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/placecast-select
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlacecastSelectRequest'
      responses:
        '202':
          description: Selection accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PlacecastSelection'
                  meta:
                    $ref: '#/components/schemas/Meta'

    get:
      operationId: listPlacecastSelections
      summary: List Placecast Select scenarios
      x-credit-cost: 1
      x-motionworks-status: production
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, processing, complete, failed]
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        '200':
          description: Paginated selections
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PlacecastSelection'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
                  meta:
                    $ref: '#/components/schemas/Meta'

  /placecast/select/{selection_id}:
    get:
      operationId: getPlacecastSelection
      summary: Get selection detail and results (polling endpoint)
      x-credit-cost: 1
      x-motionworks-status: production
      parameters:
        - name: selection_id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Selection with results when complete
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PlacecastSelection'
                  meta:
                    $ref: '#/components/schemas/Meta'

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

  /placecast/tiles/tilesets:
    get:
      tags: [Tile Discovery]
      operationId: listPlacecastTilesets
      summary: List the tilesets Placecast publishes
      description: >
        Returns the customer-facing tile catalog for the Placecast
        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/placecast/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'

  /placecast/tiles/grants:
    post:
      tags: [Tile Grants]
      operationId: createPlacecastTileGrant
      summary: Mint a Placecast 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 AND have an
        explicit Placecast tile license grant
        (`placecast.profiles.building_dca_tiles` — manual-grant-only).
        Credit-pack-only orgs receive `403 TILE_GRANTS_REQUIRE_METERED`;
        unlicensed orgs receive `403 FEATURE_NOT_LICENSED`.
      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 has no explicit
                `placecast.profiles.building_dca_tiles` grant. This
                feature key is manual-grant-only; contact sales to
                license Placecast vector tiles.
              * `TILE_GRANTS_REQUIRE_METERED` — caller's org has no
                metered Stripe billing rail (credit-pack only). Contact
                sales to enable Placecast vector-tile delivery.
              * `TILE_LAYER_NOT_LICENSED` — one or more `tilesets[]`
                entries name a layer Placecast does not publish.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                tile_layer_not_licensed:
                  summary: A tileset slug is not published by Placecast
                  value:
                    error:
                      code: TILE_LAYER_NOT_LICENSED
                      message: One or more tilesets are not published by Placecast.
                      status: 403
                      request_id: req_abc123
                      product: placecast
                      context:
                        missing: [collection_areas]
                        supported: [buildings, dca]

    get:
      tags: [Tile Grants]
      operationId: listPlacecastTileGrants
      summary: List active grants for the caller's org
      description: >
        Returns the caller-org's Placecast 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'

  /placecast/tiles/grants/{id}:
    get:
      tags: [Tile Grants]
      operationId: getPlacecastTileGrant
      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/placecast/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'

  /placecast/tiles/grants/{id}/revoke:
    post:
      tags: [Tile Grants]
      operationId: revokePlacecastTileGrant
      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) ───────────────────────────────────────

  /placecast/tiles/grants/{id}/tilejson:
    get:
      tags: [Tile TileJSON]
      operationId: getPlacecastTileJson
      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/placecast/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/placecast/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 Placecast 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) ──────────────────────────────────────────────

  /placecast/tiles/{layer}/{z}/{x}/{y}.mvt:
    get:
      tags: [Tile Data]
      operationId: getPlacecastTileMvt
      summary: Fetch a single Placecast 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
        `placecast_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/placecast-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: [buildings, dca]
          description: >
            Tileset slug. Placecast publishes two layers: `buildings`
            (place-anchored building footprints) and `dca` (District
            Collection Area polygons).
        - name: z
          in: path
          required: true
          schema:
            type: integer
            minimum: 0
            maximum: 14
          description: Tile zoom level. Placecast 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. `placecast.buildings`).
            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 Placecast 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'
