openapi: '3.1.0'
info:
  title: Motionworks API - Popcast
  version: 2.0.0
  description: |
    The Popcast API is Motionworks' population intelligence family. It answers two questions: where does an audience live, and where do they spend time?

    - **Segments** — define a population segment from demographic filters (age, gender, race and ethnicity, language, household size, children, income, vehicles, PRIZM and consumer segments). Every filter is optional and filters compose as AND. A segment returns a `motionworks_segment_id` you reuse in At Home queries and Set Dynamics cohorts. The Segments endpoints are being enabled and currently return 501 `NOT_YET_AVAILABLE`; when live, 1 credit per call.
    - **Popcast At Home** — count the persons and households in a segment by primary residence, for one geography or across every geography of a type (block group, ZIP Code Tabulation Area, county, metro, DMA, state). Annual vintages. 2 credits per page.
    - **Popcast Anytime Maptiles** — vector tiles of hourly occupancy (how many people are present in a Census block group or ZIP Code Tabulation Area at each hour of the day, by day type) for each published segment. Mint a grant, hand its TileJSON URL to MapLibre or Mapbox GL JS, and render. Grant management is free; tile traffic is metered at 1 credit per 1,000 tiles.

    Authenticate with an org-scoped API key (`X-API-Key: mw_…`) or a signed-in session. The tile catalog is public. Prose guides: [Popcast At Home](https://www.mworks.com/docs/popcast-at-home/), [Popcast Anytime](https://www.mworks.com/docs/popcast-anytime/) and [Popcast Anytime Maptiles](https://www.mworks.com/docs/popcast/popcast-anytime-maptiles/).

  x-internal-notes: |
    Product model: segment x geography x year -> counts; no lat/lng at the record level (geography is a polygon reference via geography_id). The v2 scaffold modeled location profiles with lat/lng; that shape survives only as the roadmap GET /popcast/anytime/{location_id}. Sources: docs.mworks.com popcast-at-home, ref-segments, apiref-popcast-at-home, apiref-popcast-cohort-search.
  contact:
    name: Motionworks AI
    url: https://mworks.com
    email: api@mworks.com

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

tags:
  - name: Popcast
    description: |
      Define population segments and count where they live. At Home counts cost 2 credits per page. The Segments endpoints are being enabled and currently return 501 `NOT_YET_AVAILABLE`; when live they cost 1 credit per call.

  - name: Tile Discovery
    description: |
      The public tile catalog: every Popcast Anytime tileset, with its segment, coverage window, geography family, zoom range and field schema. No authentication, no charge.

    x-internal-notes: |
      Admitted set is resolved dynamically from the POPCAST_EXTRA_METADATA KV manifest verified slugs (MA-41, 2026-08-11 one-shape ruling): manifest is the sole source of truth, no static overlay, no fail-stale floor. KV unbound / missing / malformed / older than 3h -> empty list + metadata_stale:true. Admission on every surface is manifest-verified AND a usable per-slug popcast:em:<slug> entry.
  - name: Tile Grants
    description: |
      Mint, list, read and revoke tile-delivery grants. A grant belongs to your organization and names the web origins allowed to fetch tiles. Free.

    x-internal-notes: |
      Supabase JWT or org-scoped X-API-Key; the MA-32 router Tier-A regex widening for the interposed `anytime` segment makes X-API-Key live on these four operations. Product activation MA-31; worker delivery MA-32.
  - name: Tile TileJSON
    description: |
      The TileJSON manifest for a grant — the URL you hand to your map library. The manifest embeds a short-lived tile token in its tile URLs, so your map library needs no auth header.

  - name: Tile Data
    description: |
      The tile bytes themselves (Mapbox Vector Tile, binary). Your map library requests these with the token from the TileJSON manifest; you will not normally call this endpoint yourself.


security:
  - apiKey: []

components:
  securitySchemes:
    apiKey:
      type: apiKey
      name: X-API-Key
      in: header
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A signed-in user session (`Authorization: Bearer <jwt>`), as used by the Motionworks console and account flows to manage tile grants for your organization.
    tileTokenAuth:
      type: apiKey
      in: query
      name: token
      description: >
        Opaque per-grant tile-token (24-hour TTL) minted by
        `GET /v2/popcast/tiles/anytime/grants/{id}/tilejson`. Accepted
        ONLY as the `?token=` query parameter — header form is rejected.
        Treat as a short-lived bearer credential.

  schemas:
    PopcastSegmentDefinition:
      type: object
      x-motionworks-status: production
      x-motionworks-source: popcast-cohort-search
      x-motionworks-source-doc: https://docs.mworks.com/docs/ref-segments
      required: [customer_segment_id, customer_segment_name]
      description: |
        A population segment defined from demographic filters. Every filter is optional (omit = no restriction) and filters compose as AND.
      properties:
        customer_segment_id:
          type: string
          description: "Your own identifier for the segment, echoed back on every row."
        customer_segment_name:
          type: string
          description: Your own display name for the segment.
        age_min:
          type: integer
          minimum: 0
          maximum: 100
          description: "Minimum age in years, inclusive."
        age_max:
          type: integer
          minimum: 0
          maximum: 100
          description: "Maximum age in years, inclusive."
        gender:
          type: string
          enum: [f, m]
          description: "`f` or `m`. Omit for all genders."
        races:
          type: array
          items:
            type: string
            enum: [white, black, native, asian, islander, other, multiple]
          description: Race codes to include (any match).
        hispanic:
          type: boolean
          description: "Restrict to Hispanic or Latino persons (`true`) or to persons who are not (`false`)."
        worker:
          type: boolean
          description: "Restrict to persons in the workforce (`true`) or not (`false`)."
        language:
          type: array
          items:
            type: string
            enum: [english, spanish, other]
          description: Language-spoken-at-home codes to include (any match).
        household_size_min:
          type: integer
          minimum: 1
          maximum: 20
          description: "Minimum household size, inclusive."
        household_size_max:
          type: integer
          minimum: 1
          maximum: 20
          description: "Maximum household size, inclusive."
        household_children:
          type: boolean
          description: "Restrict to households with children (`true`) or without (`false`)."
        household_income_min:
          type: integer
          description: "Minimum household income in USD, inflation-adjusted to the vintage year, inclusive. May be negative."
        household_income_max:
          type: integer
          description: "Maximum household income in USD, inflation-adjusted to the vintage year, inclusive."
        household_vehicles_min:
          type: integer
          minimum: 0
          maximum: 5
          description: "Minimum vehicles available to the household, inclusive."
        household_vehicles_max:
          type: integer
          minimum: 0
          maximum: 5
          description: "Maximum vehicles available to the household, inclusive."
        prizm_segments:
          type: array
          items:
            type: integer
          description: Claritas PRIZM Premier segment codes to include (any match).
        consumer_segments:
          type: array
          items:
            type: string
          description: Consumer segment codes to include (any match).
        year:
          type: integer
          description: Population vintage year the segment is evaluated against.

    PopcastSegment:
      allOf:
        - $ref: '#/components/schemas/PopcastSegmentDefinition'
        - type: object
          properties:
            motionworks_segment_id:
              type: string
              format: uuid
            customer_id:
              type: integer
            customer_name:
              type: string
            vintage:
              type: string

    PopcastAtHome:
      type: object
      x-motionworks-status: production
      x-motionworks-source: popcast-at-home
      x-motionworks-source-doc: https://docs.mworks.com/docs/popcast-at-home
      properties:
        motionworks_segment_id:
          type: string
          description: The Motionworks identifier of the segment counted.
        customer_segment_id:
          type: string
          description: "Your own identifier for the segment, as supplied when it was defined."
        customer_segment_name:
          type: string
          description: Your own display name for the segment.
        year:
          type: integer
          description: Population vintage year of the counts.
        geography_id:
          type: string
          description: |
            Geography identifier, for example `US2010XXBG360610031001` (block group), `US2020STCO36061` (county) or `US2020XDMA532` (DMA).
        persons:
          type: integer
          description: Persons in the segment who live in this geography.
        households:
          type: ['integer', 'null']
          description: |
            Households with at least one person matching the segment in this geography. Currently null — a household-level breakdown is planned.
          x-internal-notes: |
            null until the data service exposes household-level breakdown (MA-5 gap); hh_*/gq_* not returned by intermx-data-service.
        all_persons:
          type: integer
          description: "Total persons in the geography, regardless of segment."
        all_households:
          type: ['integer', 'null']
          description: |
            Total households in the geography, regardless of segment. Currently null — a household-level breakdown is planned.

        hh_persons:
          type: ['integer', 'null']
          description: Segment persons living in households (not group quarters). Currently null.
        hh_households:
          type: ['integer', 'null']
          description: Segment households. Currently null.
        gq_persons:
          type: ['integer', 'null']
          description: |
            Segment persons living in non-institutional group quarters (college dormitories, military barracks and similar). Currently null.
          x-internal-notes: |
            Docs (popcast-at-home.md): gq_* = NON-institutional group quarters; institutional GQ (correctional facilities, nursing homes) is inside all_persons only, so hh_persons + gq_persons < all_persons. Null today: hh_*/gq_* not returned by intermx-data-service (MA-5 gap).
        gq_households:
          type: ['integer', 'null']
          description: |
            Segment households in non-institutional group quarters — each group-quarters resident counts as one household, so this equals `gq_persons`. Currently null.
          x-internal-notes: "Docs (popcast-at-home.md): gq_households == gq_persons by construction. Null today (MA-5 gap)."
        vintage:
          type: string
          description: Data vintage label for the counts.
      description: |
        One geography's At Home counts for a segment: how many persons and households in the segment live there, with the geography's totals for context.
    PopcastAnytime:
      type: object
      x-motionworks-status: roadmap
      x-motionworks-roadmap-issue: "TF-89"
      x-motionworks-source-doc: https://docs.mworks.com/docs/popcast-at-home
      description: Hourly population profile for a single location. Coming soon — not yet returned by the API.
      x-internal-notes: ROADMAP (TF-89). 404 outside roadmap mode; 501 NOT_YET_AVAILABLE in roadmap mode.
      properties:
        location_id:
          type: string
          description: Location identifier.
        daytime_population:
          type: integer
          description: Estimated persons present during daytime hours.
        nighttime_population:
          type: integer
          description: Estimated persons present during nighttime hours.
        flow_index:
          type: number
          description: Ratio of daytime to nighttime population.
        hourly_population:
          type: array
          items:
            type: integer
          minItems: 24
          maxItems: 24
          description: Estimated persons present for each hour of the day.
        daypart_composition:
          type: array
          items:
            type: object
          description: Population by daypart.
    Pagination:
      type: object
      properties:
        cursor:
          type: string
          nullable: true
          description: "Opaque cursor for the next page. Pass it back as `cursor` to continue; absent on the last page."
        has_more:
          type: boolean
          description: Whether another page follows.
        total:
          type: integer
          description: "Total matching results, when the endpoint reports one."

    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
          description: The Motionworks dataset or model the result came from.
        source_doc:
          type: string
          format: uri
          description: Link to the methodology or dataset documentation for this result.
        methodology_version:
          type: string
          description: Version of the methodology that produced the result.
        data_vintage:
          type: string
          format: date
          description: The date the underlying data represents.
        data_freshness:
          type: string
          description: "How often the underlying data is refreshed (for example `daily`, `monthly`, `annually`)."
        data_latency_days:
          type: integer
          description: Typical days between an observation and its availability in the API.
        data_maturity:
          type: string
          enum: [production, research-preview, synthetic-only, roadmap]
          description: |
            Maturity of the data behind the result: `production` (supported and stable), `research-preview` (available, subject to change), `synthetic-only` or `roadmap` (not yet backed by production data).
        is_focused:
          type: boolean
          description: |
            Whether the place is directly measured (`true`) rather than estimated from its peer group (`false`). When present alongside `measurement_method`, the two agree.
          x-internal-notes: |
            Mirrors ProvenanceSchema.is_focused in packages/types/src/schemas/provenance.ts; paired with measurement_method by invariant F2.
        measurement_method:
          type: string
          enum: [direct, estimated]
          description: |
            `direct` when the place is measured directly; `estimated` when its statistics come from peer-group inference. When present alongside `is_focused`, the two agree (`true` ↔ `direct`).

    Meta:
      type: object
      properties:
        request_id:
          type: string
          description: Unique id for this request. Quote it when contacting support.
        credits_used:
          type: integer
          description: Credits charged for this call.
        credits_remaining:
          type: integer
          description: Credits remaining on your account after this call.
        product:
          type: string
          description: The Motionworks product that served the call.
        version:
          type: string
          description: API version that served the call.
        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
              description: |
                Per-error structured fields. Where several sub-causes share a status, `context.reason` names the specific one (see each operation's error list).
              additionalProperties: true
          description: "The error: a stable `code`, a human-readable `message`, and optional `context` with the specifics."
    TileMeta:
      type: object
      x-motionworks-status: production
      required: [request_id, credits_used, product]
      properties:
        request_id:
          type: string
          description: Unique id for this request.
        credits_used:
          type: integer
          enum: [0]
          description: "Always 0 — grant management is free; tile traffic is metered separately at 1 credit per 1,000 tiles."
        product:
          type: string
          enum: [popcast]
          description: "Always `popcast`."

    TilesetCatalogLayer:
      type: object
      x-motionworks-status: production
      description: "TileJSON 3.0.0 `vector_layers[]` entry — one per MVT layer inside the tiles."
      required: [id, fields]
      properties:
        id:
          type: string
          description: |
            Layer id inside the tile bytes (the MVT `source-layer`). Always `default` for Popcast Anytime tilesets — not the URL slug.
          x-internal-notes: |
            buildAnytimeSpec (tilesets.ts) emits exactly one layer, id 'default', minzoom 0, maxzoom 13, V4_FIELDS.
        description:
          type: string
          description: What the layer contains.
        minzoom:
          type: integer
          minimum: 0
          description: Lowest zoom level the layer serves.
        maxzoom:
          type: integer
          minimum: 0
          description: Highest zoom level the layer serves.
        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, grant_create_url]
      properties:
        slug:
          type: string
          description: |
            Tileset slug. Matches the `{layer}` URL path segment used in `GET /v2/popcast/tiles/anytime/{layer}/{z}/{x}/{y}.mvt` and the values accepted in `TileGrantCreateRequest.tilesets`. Take slugs from the catalog (`GET /v2/popcast/tiles/anytime/tilesets`) rather than constructing them. They follow `tiles_anytime_<format>_<segment id>_<start yyyymm>_<end yyyymm>_<geography family>_v1`, where the geography family is `bg` (Census block groups) or `zcta` (ZIP Code Tabulation Areas).
          x-internal-notes: Derived from the BQ table naming convention.
        title:
          type: string
          description: Human-readable display title for this tileset.
        minzoom:
          type: integer
          minimum: 0
          description: Lowest zoom level the tileset serves.
        maxzoom:
          type: integer
          minimum: 0
          description: Highest zoom level the tileset serves.
        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'
          description: "The MVT layers inside the tiles, with their field schemas."
        audkey:
          type: string
          description: |
            The segment's 16-character identifier (the `motionworks_segment_id` used in Popcast Anytime data). Also embedded in the slug.
          x-internal-notes: Parsed from the BQ table name.
        display_name:
          type: string
          description: "Display name of the segment, suitable for a picker."
        yyyymm_start:
          type: string
          description: "First month of the coverage window (`YYYYMM`)."
        yyyymm_end:
          type: string
          description: "Last month of the coverage window (`YYYYMM`)."
        format_tag:
          type: string
          description: |
            Reserved; not returned today. Would carry the version tag of the tile field layout (for example `v2_3`), which is currently embedded in the slug.
          x-internal-notes: |
            Declared but not emitted by the catalog handler (index.ts). Metadata constant from the MA-19 naming convention; today only visible as the <fmtver> slug segment.

        grains:
          type: array
          items:
            type: string
          description: |
            Reserved; not returned today. Would list the geography grains present in the tileset, switched by zoom: `ST` (state), `STCO` (county), `TRCT` (census tract) and `BG` (block group) for the block-group family; `ZCTA` replaces `TRCT`/`BG` in the ZIP Code family.
          x-internal-notes: |
            Declared but not emitted by the catalog handler (index.ts). The grain ladder is fixed per family in the tile schema; the per-grain breakpoints live in tilejson x-mw.extra_metadata.
        day_types:
          type: array
          items:
            type: integer
          description: |
            Reserved; not returned today. Would list the day-type slots in each hourly field: `1` Mon–Thu, `2` Fri, `3` Sat, `4` Sun.
          x-internal-notes: |
            Declared but not emitted by the catalog handler (index.ts). The four-slot layout is constant across the v2_3 tilesets.
        hours:
          type: integer
          description: |
            Reserved; not returned today. Would give the number of hourly occupancy fields (24 = `occ_h00` … `occ_h23`).
          x-internal-notes: Declared but not emitted by the catalog handler (index.ts). Constant 24 in the V4_FIELDS schema.
        grant_create_url:
          type: string
          format: uri
          description: |
            The endpoint to POST to for a grant that covers this tileset (`POST /v2/popcast/tiles/anytime/grants`). Minting a grant and reading its TileJSON is the only path from this catalog to tiles.
          x-internal-notes: |
            ADR-027 grant surface; emitted on every row since MA-44 (2026-08-11). Replaced the retired tilejson_url_template (www.mworks.com/api/carto/tilejson/anytimepop) which leaked a live CARTO access_token to anonymous callers.
        licensed:
          type: boolean
          description: |
            Whether your organization is licensed for this tileset. This public endpoint has no caller context, so the flag is currently always `false`; licensing is checked when you mint a grant.
          x-internal-notes: "Phase-2: hardcoded false; the ADR-029 entitlement read is the eventual flip."
    TilesetCatalogResponse:
      type: object
      x-motionworks-status: production
      required: [tilesets, metadata_stale]
      properties:
        tilesets:
          type: array
          items:
            $ref: '#/components/schemas/TilesetCatalogEntry'
          description: |
            Every Popcast Anytime tileset currently published. Empty while the catalog is being refreshed (see `metadata_stale`).
        metadata_stale:
          type: boolean
          description: |
            `false` when the catalog is current. `true` when it could not be refreshed — the list is then empty; retry a few minutes later. Do not assume a fixed number of rows.
          x-internal-notes: |
            MA-41 freshness flag. True = KV unbound / missing / malformed / older than 3h -> admitted set empty, zero rows, no static floor (2026-08-11 ruling).
    TileBreakpointArray:
      type: array
      minItems: 5
      maxItems: 5
      items:
        type: number
      description: |
        Five strictly ascending breakpoint values for one geography grain (`ST`, `STCO`, `TRCT` or `BG`), ready to use as MapLibre `interpolate` stops.
      x-internal-notes: |
        Worker enforces strict ascent at merge time (one-ULP-at-magnitude nudge on tie edges) so interpolate never sees duplicate stops.
    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: 0
          maxItems: 12
          items:
            type: string
          description: |
            Tileset slugs to include, taken from the catalog (see `TilesetCatalogEntry.slug`). An empty array means every tileset currently published — the grant is stored with the expanded list, so tilesets published later need a new grant. A slug that is not published returns 403 `TILE_LAYER_NOT_LICENSED`.
          x-internal-notes: |
            Entitlement is one-feature-all-tilesets (popcast.anytime.segment_occ_tiles), all-you-can-eat per the MA-31 ruling. Empty array expanded by the worker; persisted row carries the concrete set. Admission is the two-conjunct gate (manifest-verified AND usable per-slug KV entry); empty-array requests silently narrow to the servable subset. Legacy anytime_<16hex>_202501-202512_bg_tiles_v4 family retired.
        allowed_origins:
          type: array
          items:
            type: string
          description: |
            Browser `Origin` allowlist for the TileJSON and tile requests made with this grant. Requests must carry an `Origin` header that matches one entry. To test from curl or a native app, include the literal string `null` in `allowed_origins`. A request without an `Origin` header is otherwise refused (403 `ORIGIN_NOT_ALLOWED`, `error.context.reason: origin_required`). Wildcards are not supported — list each origin literally.
          x-internal-notes: |
            Round-2 (code check 2026-09-06): tileOriginAllowed: a null Origin passes only when allowed_origins includes the literal "null" (index.ts l.283-285); /tilejson answers 403 ORIGIN_NOT_ALLOWED context.reason origin_required on a missing Origin (l.1061-1068), origin_not_allowed on a mismatch (l.1052-1059); the .mvt handler repeats both checks (l.1378-1395).
        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: |
        A Popcast Anytime tile-delivery grant. The `grant_id` is a Crockford-base32 ULID and is itself a bearer credential — anyone who knows it 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
          description: The organization that owns the grant.
        product:
          type: string
          enum: [popcast]
          description: "Always `popcast`."
        name:
          type: string
          description: The label you gave the grant.
        tilesets:
          type: array
          items:
            type: string
          description: Tileset slugs the grant covers.
        allowed_origins:
          type: array
          items:
            type: string
          description: Browser origins allowed to fetch TileJSON and tiles with this grant.
        expires_at:
          type: string
          format: date-time
          description: When the grant expires (ISO 8601).
        revoked_at:
          type: string
          format: date-time
          nullable: true
          description: "When the grant was revoked, or null while it is active."
        created_at:
          type: string
          format: date-time
          description: When the grant was created (ISO 8601).
        created_by:
          type: string
          format: uuid
          nullable: true
          description: |
            The user who created the grant. For a grant minted with an API key, this is the user who created that key.
          x-internal-notes: |
            writerUserId: the JWT sub for a signed-in session, or api_keys.created_by (the user who provisioned the key) for X-API-Key auth. A key with no creating user cannot mint (403 API_KEY_NO_AUDIT_USER).

            Round-2 (code check 2026-09-06): insert stamps created_by: auth.writerUserId (index.ts l.735) after the API_KEY_NO_AUDIT_USER guard (l.697); writerUserId = api_keys.created_by for key auth (l.392-424).
        usage_30d:
          type: integer
          description: 30-day tile fetch count. Reported as 0 today; usage aggregation is coming.

    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
          description: Tileset slugs the grant covers.
        allowed_origins:
          type: array
          items:
            type: string
          description: Browser origins allowed to fetch TileJSON and tiles with this grant.
        expires_at:
          type: string
          format: date-time
          description: When the grant expires (ISO 8601).
        created_at:
          type: string
          format: date-time
          description: When the grant was created (ISO 8601).

    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
          description: "TileJSON specification version (`3.0.0`)."
        name:
          type: string
          description: Name of the tileset.
        tiles:
          type: array
          items:
            type: string
            format: uri
          description: "Tile URL templates with the tile token already embedded. Your map library substitutes `{z}/{x}/{y}`."
        minzoom:
          type: integer
          description: Lowest zoom level served.
        maxzoom:
          type: integer
          description: Highest zoom level served.
        bounds:
          type: array
          items:
            type: number
          minItems: 4
          maxItems: 4
          description: "Geographic extent `[west, south, east, north]` in WGS84."
        attribution:
          type: string
          description: Attribution text to show on the map.
        vector_layers:
          type: array
          items:
            type: object
          description: The MVT layers in the tiles and their field schemas.
        x-mw:
          type: object
          description: |
            Motionworks-specific metadata: the grant, the tilesets served, token expiry and refresh guidance, and per-tileset styling metadata.

          properties:
            grant_id:
              type: string
              description: The grant this manifest was minted for.
            product:
              type: string
              enum: [popcast]
              description: "Always `popcast`."
            tilesets:
              type: array
              items:
                type: string
              description: Tileset slugs this manifest serves right now.
            token_expires_at:
              type: string
              format: date-time
              description: When the embedded tile token expires (ISO 8601). Re-fetch the TileJSON before then.
            refresh_after:
              type: integer
              description: Seconds after minting at which to re-fetch this TileJSON — one hour before the tile token expires.
            extra_metadata:
              type: object
              description: |
                Per-tileset styling metadata keyed by slug: the segment, the coverage window, and density `breakpoints` per geography grain for building a choropleth ramp.
              additionalProperties:
                type: object
                required: [breakpoints, refreshed_at, extra_metadata_verified, source]
                additionalProperties: true
                properties:
                  breakpoints:
                    type: object
                    required: [d, m, p, v]
                    properties:
                      d:
                        type: object
                        description: >
                          Per-grain per-measure rank arrays. Each key is a
                          grain name (e.g. 'ST', 'STCO', 'TRCT', 'BG', or
                          future grains upstream publishes); each value is
                          an array of arrays of numbers (one array per
                          measure, aligned to breakpoints.p length).
                        additionalProperties:
                          type: array
                          items:
                            type: array
                            items:
                              type: number
                      m:
                        type: array
                        minItems: 1
                        description: >
                          Measure names (e.g. ['density', 'occupancy']).
                        items:
                          type: string
                      p:
                        type: array
                        minItems: 1
                        description: >
                          Percentile ranks the breakpoint arrays are sampled
                          at.
                        items:
                          type: number
                      v:
                        type: number
                        description: >
                          Upstream extra_metadata payload version.
                  refreshed_at:
                    type: string
                    format: date-time
                    description: "When this tileset's styling metadata was last refreshed (ISO 8601, UTC)."
                    x-internal-notes: Timestamp of the last KV write by the Go refresher.
                  extra_metadata_verified:
                    type: boolean
                    const: true
                    description: "Always `true` — metadata is only emitted for tilesets whose entry has been verified."
                    x-internal-notes: The pre-MA-41 floor variant that set this false is retired.
                  source:
                    type: string
                    const: bigquery-extra-metadata-kv
                    description: Where the metadata entry was produced. Informational.

                    x-internal-notes: Identifies a BigQuery-sourced KV entry.
paths:
  # ─── Cohort Search (segment CRUD) ────────────────────────────────────

  /popcast/segments:
    post:
      operationId: createPopcastSegment
      summary: Define a population segment
      description: |
        Creates a population segment from demographic filters. Every filter is optional — omit a filter to place no restriction on it — and filters compose as AND. The response is the resolved segment with its `motionworks_segment_id`, which you reuse in `GET /v2/popcast/at-home` and in Set Dynamics cohorts.

        This endpoint is being enabled and currently returns 501 `NOT_YET_AVAILABLE`. When live: 1 credit per call.
      x-internal-notes: |
        Handler is notYetWired() -> 501 NOT_YET_AVAILABLE (services/popcast/src/index.ts, "Cohort Search (stub)"). x-motionworks-status stays production by contract; only the prose says it is not live yet. Spec declares no 501 response on these ops (not added).
      tags:
        - Popcast
      x-credit-cost: 1
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/reference/apiref-popcast-cohort-search
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PopcastSegmentDefinition'
      responses:
        '201':
          description: Segment created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PopcastSegment'
                  meta:
                    $ref: '#/components/schemas/Meta'

    get:
      operationId: listPopcastSegments
      summary: "List your organization's segments"
      description: |
        Returns the segments your organization has defined, with cursor pagination. This endpoint is being enabled and currently returns 501 `NOT_YET_AVAILABLE`. When live: 1 credit per call.
      x-internal-notes: |
        Handler is notYetWired() -> 501 NOT_YET_AVAILABLE (services/popcast/src/index.ts, "Cohort Search (stub)"). x-motionworks-status stays production by contract; only the prose says it is not live yet. Spec declares no 501 response on these ops (not added).
      tags:
        - Popcast
      x-credit-cost: 1
      x-motionworks-status: production
      parameters:
        - name: cursor
          in: query
          schema:
            type: string
          description: "Cursor from the previous page's `pagination.cursor`. Omit for the first page."
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
          description: Results per page.
      responses:
        '200':
          description: A page of segments.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PopcastSegment'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
                  meta:
                    $ref: '#/components/schemas/Meta'
  /popcast/segments/{segment_id}:
    get:
      operationId: getPopcastSegment
      summary: Read one segment
      description: |
        Returns one of your organization's segments by `motionworks_segment_id`. This endpoint is being enabled and currently returns 501 `NOT_YET_AVAILABLE`. When live: 1 credit per call.
      x-internal-notes: |
        Handler is notYetWired() -> 501 NOT_YET_AVAILABLE (services/popcast/src/index.ts, "Cohort Search (stub)"). x-motionworks-status stays production by contract; only the prose says it is not live yet. Spec declares no 501 response on these ops (not added).
      tags:
        - Popcast
      x-credit-cost: 1
      x-motionworks-status: production
      parameters:
        - name: segment_id
          in: path
          required: true
          schema:
            type: string
          description: "The `motionworks_segment_id` returned when the segment was created."
      responses:
        '200':
          description: The segment.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/PopcastSegment'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '404':
          description: "Unknown `segment_id`, or the segment belongs to a different organization."
  # ─── At Home ─────────────────────────────────────────────────────────

  /popcast/at-home:
    get:
      operationId: getPopcastAtHome
      summary: "Count a segment's residents by geography"
      tags: [Popcast]
      description: |
        Returns one row per geography: how many persons and households in the segment live there, alongside the geography's total population for context. Two ways to query:

        - **One geography** — pass `segment_id` and `geography_id` for a single row.
        - **Every geography of a type** — pass `segment_id` and `geography_type` (omit `geography_id`) and page through the rows with `cursor`.

        Counts come from an annual population vintage; pass `year` to pick one, or omit it for the current vintage. 2 credits per page.
      x-internal-notes: |
        Live in production (DATA_SOURCE=production; the preview environment returns 501 NOT_YET_AVAILABLE). Adapter (data-service-adapter.ts) maps only year 2022 -> vintage 20230909 via YEAR_TO_VINTAGE; any other year is forwarded without a vintage, i.e. the data service default (current). The handler nests pagination under meta.pagination while the response schema declares a top-level pagination block — spec drift, not changed here. Handler also emits 404 NOT_FOUND for an unresolvable segment_id, 400 VALIDATION_ERROR, 502 UPSTREAM_UNAVAILABLE; none are declared on this op (not added).
      x-credit-cost: 2
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/popcast-at-home
      parameters:
        - name: segment_id
          in: query
          required: true
          schema:
            type: string
          description: "The `motionworks_segment_id` of the segment to count."
        - name: geography_id
          in: query
          schema:
            type: string
          description: |
            A single geography to count, for example `US2010XXBG360610031001` (block group) or `US2020STCO36061` (county). Omit it and pass `geography_type` to iterate across every geography of that type.
        - name: year
          in: query
          schema:
            type: integer
          description: Population vintage year. Omit for the current vintage.
          x-internal-notes: |
            No default in the schema. Only 2022 is mapped to a vintage today (YEAR_TO_VINTAGE in data-service-adapter.ts); other values fall through to the current vintage.
        - name: geography_type
          in: query
          schema:
            type: string
            enum: [stco, bg, dma, cbsa, state, zcta]
          description: |
            Geography level to iterate when `geography_id` is omitted: `bg` (Census block group), `zcta` (ZIP Code Tabulation Area), `stco` (county), `cbsa` (metropolitan or micropolitan statistical area), `dma` (Nielsen DMA) or `state`.
        - name: cursor
          in: query
          schema:
            type: string
          description: "The `cursor` from the previous response's pagination block. Omit for the first page."
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 5000
            default: 100
          description: Rows per page.
      responses:
        '200':
          description: A page of At Home rows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PopcastAtHome'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
                  meta:
                    $ref: '#/components/schemas/Meta'

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

  /popcast/tiles/anytime/tilesets:
    get:
      tags: [Tile Discovery]
      operationId: listPopcastAnytimeTilesets
      summary: List the Popcast Anytime tilesets
      description: |
        Returns the public tile catalog for Popcast Anytime: one entry per published tileset with its `slug`, `title`, segment (`audkey` and `display_name`), coverage window (`yyyymm_start` / `yyyymm_end`), zoom range (`minzoom` / `maxzoom`), `bounds`, the TileJSON `vector_layers[]` field schema (`layers`), `grant_create_url` and `licensed`. Use it to choose the `tilesets[]` for a grant, or to build a segment picker without hardcoding the catalog.

        Every row carries `grant_create_url` — the endpoint to POST to for a grant that covers it. The catalog can be empty when it is being refreshed; `metadata_stale: true` tells you so, and a retry a few minutes later returns the full list. Do not assume a fixed number of rows.

        No authentication required and no charge. Responses are not cached (`Cache-Control: private, no-store`).
      x-internal-notes: |
        Catalog driven by the popcast:em:_manifest blob + per-slug popcast:em:<slug> entries in POPCAST_EXTRA_METADATA KV (MA-41 one-shape ruling, 2026-08-11): no static projection overlay, no CATALOG_PROJECTION_OVERLAY, no fail-stale floor; unbound/missing/malformed/>3h -> empty list + metadata_stale:true (fail-loud). Two-conjunct admission everywhere (catalog, grants, tilejson, .mvt). Only admitted slug shape tiles_anytime_<fmtver>_<16hex>_<yyyymm>_<yyyymm>[_<grain>]_v1 (N=1 only; legacy anytime_<16hex>_202501-202512_bg_tiles_v4 retired). display names from extra_metadata.segment.segment_name (row omits display_name when the entry has none). licensed hardcoded false in Phase-2 (ADR-029 read is the flip). grant_create_url on every row since MA-44 (2026-08-11); the retired tilejson_url_template (www.mworks.com/api/carto/tilejson/anytimepop) leaked a live CARTO access_token + FQNs and is gone. No Supabase or CARTO calls on the hot path. Emitted row keys (index.ts catalog builder, verified against the live endpoint 2026-09-06, 372 rows): slug, title, minzoom, maxzoom, bounds, layers, audkey, display_name, yyyymm_start, yyyymm_end, grant_create_url, licensed. TilesetCatalogEntry.format_tag / grains / day_types / hours are declared but NEVER emitted — documented as reserved.
      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'

  # ─── Anytime Tile Grants (MA-31 product activation) ───────────────────
  #
  # PRICING-ONLY today: these ops are live in PRICING_MANIFEST v1.15.0
  # (MA-31) so grants become mintable the moment the enforcement seam in
  # the popcast worker lands (MA-32). Until the worker ships, callers hit
  # the router -> popcast dispatch and receive 501 NOT_YET_AVAILABLE —
  # the same clean-slate posture as every unwired route. The wire
  # contract below is the one MA-32 implements against.

  /popcast/tiles/anytime/grants:
    post:
      tags: [Tile Grants]
      operationId: createPopcastAnytimeTileGrant
      summary: Mint a Popcast Anytime vector-tile grant
      description: |
        Creates a tile-delivery grant for your organization and returns its capability `tile_json_url`. Hand that URL to MapLibre, Mapbox GL JS or any TileJSON-aware client and it starts rendering. Free — grant management costs 0 credits; only tile traffic is metered.

        A grant covers the tilesets you list, or every published Popcast Anytime tileset when `tilesets` is empty. Licensing is per organization rather than per segment: once your organization is licensed for Popcast Anytime tiles, one grant can cover the whole catalog.

        Authenticate with an org-scoped API key (`X-API-Key: mw_…`) or a signed-in session (`Authorization: Bearer <jwt>`); anonymous callers cannot mint grants. Popcast Anytime tiles are licensed by arrangement — contact sales to enable them for your organization.
      x-internal-notes: |
        Entitlement popcast.anytime.segment_occ_tiles is manual-grant-only (explicit org_feature_grants row). One grant is all-you-can-eat across the admitted set, no per-segment scoping (MA-31 ruling). Auth posture matches the sibling /v2/<product>/tiles/grants families via the MA-32 TILE_GRANT_CRUD_PATTERN widening (one optional interposed consumer-product segment).
      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: |
            Common cases:
              * `FEATURE_NOT_LICENSED` — your organization is not licensed for Popcast Anytime vector tiles. Contact sales.
              * `TILE_GRANTS_REQUIRE_METERED` — tile delivery for your organization requires pay-as-you-go (metered) billing, which is not enabled. Contact sales to enable tile delivery.
              * `TILE_LAYER_NOT_LICENSED` — one or more `tilesets[]` entries name a tileset Popcast Anytime does not publish.
              * `API_KEY_NO_AUDIT_USER` — the API key is not tied to a user; mint with a signed-in session or a key created by a user.
          x-internal-notes: |
            Gate order: license check runs first at mint and tilejson time; the rail check only runs under TILE_METER_MODE=direct. Under the deployed accumulate mode TILE_GRANTS_REQUIRE_METERED is unreachable (credit-pack wallets billed in-transaction by accrue_tile). Entitlement is one-feature-all-tilesets, so TILE_LAYER_NOT_LICENSED only fires on an unknown / non-servable slug, never a licensed-subset rejection. Also 403 FORBIDDEN "User has no organization memberships." when the resolved principal has zero orgIds (index.ts create handler) — left out of the customer list as an account-setup edge case. API_KEY_NO_AUDIT_USER fires when api_keys.created_by is null (writerUserId null); the revoke op emits it too but declares no 403.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

    get:
      tags: [Tile Grants]
      operationId: listPopcastAnytimeTileGrants
      summary: "List your organization's grants"
      description: "Returns your organization's Popcast Anytime tile grants, most recent first. Free."
      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'

  /popcast/tiles/anytime/grants/{id}:
    get:
      tags: [Tile Grants]
      operationId: getPopcastAnytimeTileGrant
      summary: Read one grant
      description: "Returns one grant by id, scoped to your organization. Free."
      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/popcast/tiles/anytime/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: |
            No grant with this id in your organization. Expired and revoked grants are still returned so you can inspect them.
          x-internal-notes: |
            Round-2 (code check 2026-09-06): the read/revoke 404 condition is not_found || org mismatch || product mismatch only (index.ts l.871-875 read, l.912-916 revoke); expires_at is checked only by /tilejson (l.1039-1042). The old "expired" wording was wrong.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /popcast/tiles/anytime/grants/{id}/revoke:
    post:
      tags: [Tile Grants]
      operationId: revokePopcastAnytimeTileGrant
      summary: Revoke a grant immediately
      description: |
        Revokes the grant immediately. New TileJSON requests for it return 404, and tile requests using tokens already minted from it return 401 `UNAUTHORIZED` with `error.context.reason: grant_revoked`. Revoking an already-revoked grant returns 200 with the unchanged record. Free.
      x-internal-notes: |
        index.ts: the .mvt handler checks grant.revoked_at on every tile request plus the ORG_CACHE `tile_grant:<id>:revoked` KV flag written at revoke time (TTL TILE_GRANT_REVOKE_KV_TTL_SECONDS), so already-minted 24h tokens 401 grant_revoked at once — no "valid until expiry" window. Already-revoked -> 200 with the stored row, before any write. Revoke ALSO emits 403 API_KEY_NO_AUDIT_USER when the key has no creating user, but the contract declares no 403 on this op (not added — prose only). Emergency cutoff for a leaked tile-token key: rotate MW_VECTOR_TILE_JWT_KEY (operator runbook).

      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/popcast/tiles/anytime/grants`."
      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: No grant with this id in your organization.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /popcast/tiles/anytime/grants/{id}/tilejson:
    get:
      tags: [Tile TileJSON]
      operationId: getPopcastAnytimeTileJson
      summary: Get the TileJSON manifest for a grant
      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. The manifest covers the tilesets the grant authorizes that are currently published; `x-mw.tilesets` lists exactly what it serves and `x-mw.extra_metadata` carries per-tileset styling metadata such as density breakpoints.

        **This URL is a capability — treat it like a secret.** It carries the credential needed to fetch tiles inside the URL itself, like a presigned object-storage URL: anyone who has it can render your map until the grant expires or is revoked. That is by design — your front-end code can pass it straight to the map library without a separate auth header. Two safety nets are built in: the grant's `allowed_origins` pins which sites can fetch tiles, and `POST /v2/popcast/tiles/anytime/grants/{id}/revoke` ends the grant.

        Free — the tile bytes themselves are metered on `GET .../anytime/{layer}/{z}/{x}/{y}.mvt`.
      x-internal-notes: |
        A granted slug whose per-slug KV entry becomes unusable is dropped from tiles[] and x-mw.extra_metadata; x-mw.tilesets reflects the currently serviceable set.
      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: |
            `error.code` tells you which:
              * `ORIGIN_NOT_ALLOWED` — the request `Origin` is not in the grant's `allowed_origins` (`error.context.reason: origin_not_allowed`), or the request carried no `Origin` header at all (`origin_required`). Requests must carry an `Origin` header that matches one entry. To test from curl or a native app, include the literal string `null` in `allowed_origins`.
              * `FEATURE_NOT_LICENSED` — your organization is not licensed for Popcast Anytime vector tiles. Contact sales.
              * `TILE_GRANTS_REQUIRE_METERED` — tile delivery for your organization requires pay-as-you-go (metered) billing, which is not enabled. Contact sales to enable tile delivery.
          x-internal-notes: |
            License check first at mint time; rail check second only under TILE_METER_MODE=direct (see the create 403).

            Round-2 (code check 2026-09-06): tileOriginAllowed: a null Origin passes only when allowed_origins includes the literal "null" (index.ts l.283-285); /tilejson answers 403 ORIGIN_NOT_ALLOWED context.reason origin_required on a missing Origin (l.1061-1068), origin_not_allowed on a mismatch (l.1052-1059); the .mvt handler repeats both checks (l.1378-1395).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: "Grant not found, expired, or revoked."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /popcast/tiles/anytime/{layer}/{z}/{x}/{y}.mvt:
    get:
      tags: [Tile Data]
      operationId: getPopcastAnytimeTileMvt
      summary: Fetch a single Popcast Anytime vector tile (binary MVT)
      description: |
        **Your map library calls this for you.** MapLibre, Mapbox GL JS and deck.gl's `MVTLayer` use the `tiles[]` URL templates from the manifest, which already carry the token.

        Returns a Mapbox Vector Tile (binary protobuf) for one Popcast Anytime tileset. The only accepted credential is the `?token=` query parameter — header-based auth is rejected by design, so the TileJSON URL alone is enough for a browser to render. Metered at **1 credit per 1,000 tiles served**. Responses may be gzip-encoded; honor `Content-Encoding`. A tile with no features returns HTTP 204.

        Each tileset serves zoom 0–13 and switches geography grain with zoom: states at low zooms, then counties, census tracts and finally block groups (or ZIP Code Tabulation Areas for the `zcta` family). Features carry one hourly occupancy field per hour (`occ_h00` … `occ_h23`), each holding four day-type slots (Mon–Thu, Fri, Sat, Sun), plus the area fields needed to render density. See the [Popcast Anytime Maptiles guide](https://www.mworks.com/docs/popcast/popcast-anytime-maptiles/) for the field layout and a styling recipe.
      x-internal-notes: |
        Op id popcast_anytime_tiles_fetch, meter divisor 1000. Full tile-delivery contract: ADR-027 (docs/architecture/27-vector-tile-endpoints.md).
      x-credit-cost: 1
      x-motionworks-status: production
      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
          description: |
            A tileset slug from the catalog (see `TilesetCatalogEntry.slug`). A slug that is not published returns 404.
          x-internal-notes: |
            Only admitted shape (MA-41, 2026-08-11 one-shape ruling): tiles_anytime_<fmtver>_<16hex>_<yyyymm>_<yyyymm>[_<grain>]_v1; legacy anytime_<16hex>_202501-202512_bg_tiles_v4 retired (404 layer_unknown). Admitted set resolved from the KV manifest.
          schema:
            type: string
        - name: z
          in: path
          required: true
          schema:
            type: integer
            minimum: 0
            maximum: 13
          description: |
            Tile zoom level. Popcast Anytime tilesets serve z 0–13; the exact range is in the catalog entry's `minzoom` / `maxzoom`. A zoom outside the range returns 400 `INVALID_REQUEST`.

        - 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-Snapshot:
              schema:
                type: string
                example: 'static'
              description: |
                Always the literal `static`. Each Popcast Anytime tileset embeds its coverage window in its slug, so there is no separate per-request snapshot date.
              x-internal-notes: |
                Slug grammar tiles_anytime_<fmtver>_<16hex>_<yyyymm_start>_<yyyymm_end>[_<grain>]_v1 (MA-41 one-shape; legacy anytime_<16hex>_<start>-<end>_bg_tiles_v4 retired). CARTO resolves the single published snapshot of each date-embedded tileset server-side.
            X-MW-Tileset:
              schema:
                type: string
              description: "Resolved upstream tileset id (e.g. `tiles_anytime_v2_3_21d529097c81af04_202501_202512_bg_v1`)."
              x-internal-notes: |
                The legacy anytime_<16hex>_202501-202512_bg_tiles_v4 form is retired and no longer admitted (MA-41 one-shape).
          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 outside the tileset's range, 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) — `?token=` missing.
              * `expired` / `malformed` / `signature_mismatch` — the token is invalid or expired; fetch the TileJSON again.
              * `layer_not_in_tilesets` — the token does not authorize the requested tileset.
              * `grant_revoked` — the backing grant has been revoked.
              * `tile_grant_exhausted` — your organization's credits are exhausted (a billing signal, not an auth failure).
          x-internal-notes: |
            Round-2 (code check 2026-09-06): missing token → 401 with no context (index.ts l.1260-1269); every verifyTileToken failure carries context.reason = the VerifyResult reason (l.1279-1290): malformed, unsupported_alg, signature_mismatch, expired, aud_mismatch, product_mismatch, missing_claim (packages/vector-tile-auth/src/index.ts l.81-93). Only the three a customer can plausibly hit are listed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: |
            `error.code: ORIGIN_NOT_ALLOWED` — the request `Origin` is not in the grant's `allowed_origins` (`error.context.reason: origin_not_allowed`), or the request carried no `Origin` header (`origin_required`). Map libraries send `Origin` automatically; to test from curl or a native app, include the literal string `null` in the grant's `allowed_origins`.
          x-internal-notes: "Round-2 (code check 2026-09-06): same two branches as /tilejson (index.ts l.1378-1395)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: |
            `error.code: NOT_FOUND`. Disambiguated by `error.context.reason`:
              * `layer_unknown` — the tileset slug is not one Popcast Anytime publishes.
              * `upstream_unavailable` — the tileset is temporarily unavailable; retry later.
              * (no reason) — the grant referenced by the tile token was not found.
          x-internal-notes: |
            upstream_unavailable = the CARTO upstream answered 404 (snapshot/tileset outage, not a caller error).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ─── Anytime (ROADMAP TF-89) ─────────────────────────────────────────

  /popcast/anytime/{location_id}:
    get:
      operationId: getPopcastAnytime
      summary: Get hourly population by location (coming soon)
      description: |
        Coming soon. Hourly population profiles for a single location are not yet available through the API; requests return 404. Popcast Anytime data is available today as vector tiles — see the Maptiles endpoints.
      tags:
        - Popcast
      x-internal-notes: |
        ROADMAP (TF-89). The worker registers roadmapRoute() at /v2/popcast/anytime WITHOUT the {location_id} segment (index.ts), so this documented path always falls to app.notFound -> 404 NOT_FOUND regardless of data_maturity_mode; the roadmap-mode 501 NOT_YET_AVAILABLE branch is unreachable here. Fix is either a route change or a path change — not a prose matter.
      x-credit-cost: 2
      x-motionworks-status: roadmap
      x-motionworks-roadmap-issue: "TF-89"
      parameters:
        - name: location_id
          in: path
          required: true
          schema:
            type: string
          description: Location identifier (reserved).
      responses:
        '200':
          description: Anytime profile — returned only once the endpoint ships.
        '501':
          description: Reserved for roadmap previews; not returned at this path today.
        '404':
          description: Not available yet.
x-tagGroups:
  - name: Segments & At Home
    tags:
      - Popcast
  - name: Anytime Maptiles
    tags:
      - Tile Discovery
      - Tile Grants
      - Tile TileJSON
      - Tile Data
x-customer-voice: true
